This commit is contained in:
Mikan
2026-06-20 19:13:05 +03:00
parent 32575e217e
commit 8514c63ec6
193 changed files with 22105 additions and 11660 deletions

3
app/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""AI-RPG backend application package."""
__version__ = "1.0.0"

1
app/api/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""API package."""

401
app/api/admin.py Normal file
View File

@@ -0,0 +1,401 @@
"""Admin API — settings, llm logs, users, stats, test endpoints, icon upload."""
from __future__ import annotations
import os
import time
import uuid
from datetime import datetime, timezone
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, UploadFile, status
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import require_admin
from app.config import get_settings
from app.core.embeddings import (
HashEmbedder,
OpenAIEmbedder,
build_hash_embedder,
build_openai_embedder,
)
from app.core.llm import LlmClient
from app.core.logging import get_logger
from app.core.qdrant_client import init_qdrant_collections
from app.core.rag import reset_embedder_cache
from app.core.settings_service import (
DEFAULT_SETTINGS,
SECRET_KEYS,
get_all_settings,
mask_secret,
set_setting,
)
from app.db import get_db
from app.models import LlmCallLog, User
from app.schemas import LlmLogDetail, LlmLogOut, SettingsPatchRequest
_logger = get_logger(__name__)
router = APIRouter(prefix="/api/admin", tags=["admin"])
# --------------------------------------------------------------------------- #
# Settings
# --------------------------------------------------------------------------- #
@router.get("/settings")
async def get_settings_endpoint(
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
# Mask secrets
out = {k: mask_secret(k, v) for k, v in settings.items()}
return {"settings": out, "descriptions": {k: s["description"] for k, s in DEFAULT_SETTINGS.items()}}
@router.patch("/settings")
async def patch_settings_endpoint(
body: SettingsPatchRequest,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
updated = {}
for k, v in body.settings.items():
# Don't update secret keys if the masked value was sent back unchanged
if k in SECRET_KEYS and isinstance(v, str) and ("" in v or v == "****"):
continue
await set_setting(db, k, v)
updated[k] = mask_secret(k, v)
# Clear embedder cache so new settings take effect
reset_embedder_cache()
return {"updated": updated}
# --------------------------------------------------------------------------- #
# LLM logs
# --------------------------------------------------------------------------- #
@router.get("/llm-logs")
async def list_llm_logs(
world_id: uuid.UUID | None = None,
stage: str | None = None,
status_filter: str | None = None,
page: int = 1,
per_page: int = 50,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
stmt = select(LlmCallLog)
if world_id:
stmt = stmt.where(LlmCallLog.world_id == world_id)
if stage:
stmt = stmt.where(LlmCallLog.stage == stage)
if status_filter:
stmt = stmt.where(LlmCallLog.status == status_filter)
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()
return {
"items": [LlmLogOut.model_validate(r).model_dump(mode="json") for r in rows],
"total": total, "page": page, "per_page": per_page,
}
@router.get("/llm-logs/{log_id}", response_model=LlmLogDetail)
async def get_llm_log(
log_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> LlmCallLog:
log = (
await db.execute(select(LlmCallLog).where(LlmCallLog.id == log_id))
).scalar_one_or_none()
if log is None:
raise HTTPException(404, "not_found")
return log
# --------------------------------------------------------------------------- #
# Users
# --------------------------------------------------------------------------- #
@router.get("/users")
async def list_users(
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
rows = (await db.execute(select(User).order_by(User.created_at.desc()))).scalars().all()
return {"items": [
{"id": str(u.id), "email": u.email, "username": u.username,
"is_admin": u.is_admin, "is_active": u.is_active,
"created_at": u.created_at.isoformat(), "last_login_at": u.last_login_at.isoformat() if u.last_login_at else None}
for u in rows
]}
@router.patch("/users/{user_id}")
async def patch_user(
user_id: uuid.UUID,
body: dict,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
user = (
await db.execute(select(User).where(User.id == user_id))
).scalar_one_or_none()
if user is None:
raise HTTPException(404, "not_found")
if "is_admin" in body:
user.is_admin = bool(body["is_admin"])
if "is_active" in body:
user.is_active = bool(body["is_active"])
await db.commit()
return {"id": str(user.id), "is_admin": user.is_admin, "is_active": user.is_active}
# --------------------------------------------------------------------------- #
# Stats
# --------------------------------------------------------------------------- #
@router.get("/stats")
async def stats(
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
from app.models import Step, World
users_count = (await db.execute(select(func.count(User.id)))).scalar_one()
worlds_count = (await db.execute(select(func.count(World.id)))).scalar_one()
steps_count = (await db.execute(select(func.count(Step.id)))).scalar_one()
avg_latency = (
await db.execute(select(func.avg(LlmCallLog.latency_ms)))
).scalar_one()
return {
"users": users_count,
"worlds": worlds_count,
"steps": steps_count,
"avg_llm_latency_ms": float(avg_latency) if avg_latency else 0,
}
# --------------------------------------------------------------------------- #
# Test endpoints — LLM, embeddings, embeddings probe dimension
# --------------------------------------------------------------------------- #
@router.post("/test/llm")
async def test_llm(
api_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
api_url = api_url or settings.get("llm.api_url", "")
api_key = api_key or settings.get("llm.api_key", "")
model = model or settings.get("llm.model", "")
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0}
client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1)
start = time.monotonic()
try:
resp = await client.complete(
stage="test_llm",
messages=[{"role": "user", "content": "Reply with exactly: OK"}],
temperature=0.0, max_tokens=10,
session=db,
)
elapsed = int((time.monotonic() - start) * 1000)
return {
"ok": True, "response": resp["message"].get("content", "").strip(),
"model": model, "elapsed_ms": elapsed,
"prompt_tokens": resp.get("prompt_tokens"), "completion_tokens": resp.get("completion_tokens"),
}
except Exception as e: # noqa: BLE001
elapsed = int((time.monotonic() - start) * 1000)
return {"ok": False, "error": {"code": "connection_failed", "message": str(e)},
"elapsed_ms": elapsed}
@router.post("/test/llm-tools")
async def test_llm_tools(
api_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
api_url = api_url or settings.get("llm.api_url", "")
api_key = api_key or settings.get("llm.api_key", "")
model = model or settings.get("llm.model", "")
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "llm.api_url is empty"},
"elapsed_ms": 0, "has_tool_calls": False}
client = LlmClient(api_url=api_url, api_key=api_key, model=model, timeout=15.0, max_retries=1)
start = time.monotonic()
try:
tools = [{
"type": "function",
"function": {
"name": "calc",
"description": "Evaluate a math expression",
"parameters": {
"type": "object",
"required": ["expression"],
"properties": {"expression": {"type": "string"}},
},
},
}]
resp = await client.complete(
stage="test_llm_tools",
messages=[{"role": "user", "content": "What is 2+2? Use the calc tool."}],
tools=tools, temperature=0.0, max_tokens=100,
session=db,
)
elapsed = int((time.monotonic() - start) * 1000)
tcs = resp["message"].get("tool_calls") or []
return {
"ok": True, "tool_calls": tcs, "has_tool_calls": bool(tcs), "elapsed_ms": elapsed,
}
except Exception as e: # noqa: BLE001
elapsed = int((time.monotonic() - start) * 1000)
return {"ok": False, "error": {"code": "connection_failed", "message": str(e)},
"elapsed_ms": elapsed, "has_tool_calls": False}
@router.post("/test/embeddings")
async def test_embeddings(
api_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
provider: str | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
provider = provider or settings.get("embeddings.provider", "offline_hash")
start = time.monotonic()
try:
if provider == "offline_hash":
emb = build_hash_embedder(int(settings.get("embeddings.dimension", 256)))
vecs = await emb.embed(["hello world"])
elapsed = int((time.monotonic() - start) * 1000)
return {
"ok": True, "dimension": emb.dimension, "model": "offline_hash",
"first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed,
}
api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "")
api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "")
model = model or settings.get("embeddings.model", "")
if not api_url:
return {"ok": False, "error": {"code": "not_configured", "message": "no api_url"},
"elapsed_ms": 0}
emb = build_openai_embedder(
api_url=api_url, api_key=api_key, model=model,
dimension=int(settings.get("embeddings.dimension", 1536)),
timeout=15.0,
)
vecs = await emb.embed(["hello world"])
elapsed = int((time.monotonic() - start) * 1000)
return {
"ok": True, "dimension": len(vecs[0]) if vecs else 0, "model": model,
"first_5_values": vecs[0][:5] if vecs else [], "elapsed_ms": elapsed,
}
except Exception as e: # noqa: BLE001
elapsed = int((time.monotonic() - start) * 1000)
return {"ok": False, "error": {"code": "connection_failed", "message": str(e)},
"elapsed_ms": elapsed}
@router.post("/test/embeddings/probe-dimension")
async def probe_dimension(
api_url: str | None = None,
api_key: str | None = None,
model: str | None = None,
provider: str | None = None,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
settings = await get_all_settings(db)
provider = provider or settings.get("embeddings.provider", "offline_hash")
start = time.monotonic()
try:
if provider == "offline_hash":
return {
"ok": True,
"dimension": int(settings.get("embeddings.dimension", 256)),
"elapsed_ms": 0,
}
api_url = api_url or settings.get("embeddings.api_url") or settings.get("llm.api_url", "")
api_key = api_key or settings.get("embeddings.api_key") or settings.get("llm.api_key", "")
model = model or settings.get("embeddings.model", "")
emb = build_openai_embedder(
api_url=api_url, api_key=api_key, model=model,
dimension=int(settings.get("embeddings.dimension", 1536)),
timeout=15.0,
)
dim = await emb.probe_dimension()
elapsed = int((time.monotonic() - start) * 1000)
return {"ok": True, "dimension": dim, "elapsed_ms": elapsed}
except Exception as e: # noqa: BLE001
elapsed = int((time.monotonic() - start) * 1000)
return {"ok": False, "error": {"code": "probe_failed", "message": str(e)},
"elapsed_ms": elapsed}
@router.post("/embeddings/recreate-collections")
async def recreate_collections(
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
"""Drop and recreate Qdrant collections with the current embedding dimension."""
from app.core.qdrant_client import get_qdrant_client
settings = await get_all_settings(db)
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
client = get_qdrant_client()
existing = {c.name for c in (await client.get_collections()).collections}
dropped = []
for name in (f"{prefix}entities", f"{prefix}story_entries"):
if name in existing:
await client.delete_collection(name)
dropped.append(name)
result = await init_qdrant_collections(int(settings.get("embeddings.dimension", 256)))
return {"dropped": dropped, "created": result["created"], "dimension": result["dimension"]}
# --------------------------------------------------------------------------- #
# Icon upload
# --------------------------------------------------------------------------- #
@router.post("/upload-icon")
async def upload_icon(
file: UploadFile = File(...),
kind: str = Form("favicon"),
_user: User = Depends(require_admin),
db: AsyncSession = Depends(get_db),
) -> dict:
cfg = get_settings()
if kind not in ("favicon", "logo", "og_image"):
raise HTTPException(400, "kind must be one of: favicon, logo, og_image")
contents = await file.read()
if len(contents) > cfg.max_upload_size_bytes:
raise HTTPException(413, "file too large (max 1MB)")
# Validate extension
allowed_exts = {".png", ".svg", ".jpg", ".jpeg", ".webp", ".ico"}
ext = Path(file.filename or "").suffix.lower()
if ext not in allowed_exts:
raise HTTPException(400, f"unsupported extension: {ext}")
assets_dir = Path(cfg.assets_dir)
assets_dir.mkdir(parents=True, exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
fname = f"{kind}_{ts}{ext}"
out_path = assets_dir / fname
out_path.write_bytes(contents)
url = f"/static/assets/{fname}"
setting_key = {
"favicon": "ui.favicon_url",
"logo": "ui.logo_url",
"og_image": "ui.og_image_url",
}[kind]
await set_setting(db, setting_key, url)
return {"ok": True, "kind": kind, "url": url, "size_bytes": len(contents)}

224
app/api/auth.py Normal file
View File

@@ -0,0 +1,224 @@
"""Auth endpoints: register, register/admin, login, refresh, logout, me."""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import func, or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.core.security import (
create_access_token,
create_refresh_token,
decode_token,
hash_password,
validate_password_strength,
verify_password,
)
from app.core.settings_service import get_admin_setup_token
from app.db import get_db
from app.models import User
from app.schemas import (
AdminRegisterRequest,
LoginRequest,
RegisterRequest,
TokenResponse,
UserPublic,
)
router = APIRouter(prefix="/api", tags=["auth"])
async def _check_first_admin(db: AsyncSession) -> bool:
"""Return True if at least one admin exists."""
cnt = (
await db.execute(select(func.count(User.id)).where(User.is_admin.is_(True)))
).scalar_one()
return cnt > 0
@router.post("/register", response_model=UserPublic, status_code=status.HTTP_201_CREATED)
async def register(
body: RegisterRequest,
db: AsyncSession = Depends(get_db),
) -> User:
"""Register a regular user. Only allowed if at least one admin already exists."""
has_admin = await _check_first_admin(db)
if not has_admin:
raise HTTPException(
status.HTTP_403_FORBIDDEN,
"no_admin_yet_use_admin_register",
)
existing = (
await db.execute(
select(User).where(
or_(User.email == body.email, User.username == body.username)
)
)
).scalar_one_or_none()
if existing is not None:
if existing.email == body.email:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists")
raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists")
errors = validate_password_strength(body.password)
if errors:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0])
user = User(
email=body.email,
username=body.username,
password_hash=hash_password(body.password),
is_admin=False,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post(
"/register/admin",
response_model=UserPublic,
status_code=status.HTTP_201_CREATED,
)
async def register_admin(
body: AdminRegisterRequest,
db: AsyncSession = Depends(get_db),
) -> User:
"""Register the first admin user. Requires a valid setup token."""
has_admin = await _check_first_admin(db)
if has_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "admin_already_exists")
expected_token = await get_admin_setup_token(db)
if body.token != expected_token:
raise HTTPException(status.HTTP_403_FORBIDDEN, "invalid_admin_token")
existing = (
await db.execute(
select(User).where(
or_(User.email == body.email, User.username == body.username)
)
)
).scalar_one_or_none()
if existing is not None:
if existing.email == body.email:
raise HTTPException(status.HTTP_400_BAD_REQUEST, "email_already_exists")
raise HTTPException(status.HTTP_400_BAD_REQUEST, "username_already_exists")
errors = validate_password_strength(body.password)
if errors:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail=errors[0])
user = User(
email=body.email,
username=body.username,
password_hash=hash_password(body.password),
is_admin=True,
is_active=True,
)
db.add(user)
await db.commit()
await db.refresh(user)
return user
@router.post("/auth/login", response_model=TokenResponse)
async def login(
body: LoginRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
"""Login by email OR username. Returns access + refresh JWTs."""
stmt = select(User).where(
or_(User.email == body.login, User.username == body.login)
)
user = (await db.execute(stmt)).scalar_one_or_none()
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials")
if not verify_password(body.password, user.password_hash):
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "invalid_credentials")
if not user.is_active:
raise HTTPException(status.HTTP_403_FORBIDDEN, "account_disabled")
user.last_login_at = datetime.now(timezone.utc)
await db.commit()
access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin})
refresh = create_refresh_token(user.id)
return TokenResponse(
access_token=access,
refresh_token=refresh,
token_type="bearer",
expires_in=60 * 24,
user=UserPublic.model_validate(user),
)
@router.post("/auth/refresh", response_model=TokenResponse)
async def refresh_token(
db: AsyncSession = Depends(get_db),
token: str = ...,
) -> TokenResponse:
"""Exchange a refresh token for a new access + refresh pair.
The token is passed in the request body as `{refresh_token: "..."}`.
"""
raise NotImplementedError("Implemented below via RefreshRequest body")
from pydantic import BaseModel # noqa: E402
class RefreshRequest(BaseModel):
refresh_token: str
@router.post("/auth/refresh", response_model=TokenResponse, name="refresh_real")
async def refresh_real(
body: RefreshRequest,
db: AsyncSession = Depends(get_db),
) -> TokenResponse:
try:
payload = decode_token(body.refresh_token)
except Exception as e: # noqa: BLE001
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid refresh token: {e}")
if payload.get("type") != "refresh":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Not a refresh token")
user_id = uuid.UUID(payload["sub"])
user = (await db.execute(select(User).where(User.id == user_id))).scalar_one_or_none()
if user is None or not user.is_active:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found or disabled")
access = create_access_token(user.id, extra_claims={"is_admin": user.is_admin})
new_refresh = create_refresh_token(user.id)
return TokenResponse(
access_token=access,
refresh_token=new_refresh,
token_type="bearer",
expires_in=60 * 24,
user=UserPublic.model_validate(user),
)
# Remove the placeholder earlier /auth/refresh route so only the real one stays.
_refresh_routes = [r for r in router.routes if getattr(r, "path", "") == "/api/auth/refresh"]
if len(_refresh_routes) > 1:
router.routes.remove(_refresh_routes[0])
@router.post("/auth/logout", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def logout() -> Response:
"""Stateless logout — client drops the tokens."""
return Response(status_code=status.HTTP_204_NO_CONTENT)
@router.get("/auth/me", response_model=UserPublic)
async def me(current: User = Depends(get_current_user)) -> User:
"""Return the current user's profile."""
return current

64
app/api/deps.py Normal file
View File

@@ -0,0 +1,64 @@
"""Shared API dependencies: current user, admin guard, db session, settings."""
from __future__ import annotations
import uuid
from typing import Any
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.security import decode_token
from app.core.settings_service import get_all_settings
from app.db import get_db
from app.models import User
_bearer = HTTPBearer(auto_error=False)
async def get_current_user(
creds: HTTPAuthorizationCredentials | None = Depends(_bearer),
db: AsyncSession = Depends(get_db),
) -> User:
"""Resolve the JWT bearer token to a User row.
Raises 401 on missing/invalid/expired token.
"""
if creds is None or creds.scheme.lower() != "bearer":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Missing bearer token")
try:
payload = decode_token(creds.credentials)
except Exception as e: # noqa: BLE001
raise HTTPException(status.HTTP_401_UNAUTHORIZED, f"Invalid token: {e}")
if payload.get("type") != "access":
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Wrong token type")
user_id_str = payload.get("sub")
if not user_id_str:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Token missing sub")
try:
user_id = uuid.UUID(user_id_str)
except ValueError:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "Invalid user id in token")
user = (
await db.execute(select(User).where(User.id == user_id))
).scalar_one_or_none()
if user is None:
raise HTTPException(status.HTTP_401_UNAUTHORIZED, "User not found")
if not user.is_active:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Account disabled")
return user
async def require_admin(user: User = Depends(get_current_user)) -> User:
"""Require an admin user."""
if not user.is_admin:
raise HTTPException(status.HTTP_403_FORBIDDEN, "Admin only")
return user
async def get_settings_dict(db: AsyncSession = Depends(get_db)) -> dict[str, Any]:
"""FastAPI dependency: returns the full settings dict."""
return await get_all_settings(db)

74
app/api/misc.py Normal file
View File

@@ -0,0 +1,74 @@
"""Misc endpoints: health, i18n."""
from __future__ import annotations
from fastapi import APIRouter, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from app import __version__
from app.config import get_settings
from app.core.embeddings import HashEmbedder
from app.core.logging import get_logger
from app.core.qdrant_client import ping_qdrant
from app.db import get_db
from app.schemas import HealthResponse
_logger = get_logger(__name__)
router = APIRouter(prefix="/api", tags=["misc"])
@router.get("/health", response_model=HealthResponse)
async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse:
"""Health-check endpoint — no auth required."""
db_ok = False
qdrant_ok = False
try:
await db.execute(text("SELECT 1"))
db_ok = True
except Exception as e: # noqa: BLE001
_logger.warning("health_db_failed", error=str(e))
try:
qdrant_ok = await ping_qdrant()
except Exception as e: # noqa: BLE001
_logger.warning("health_qdrant_failed", error=str(e))
# LLM health: we treat it as "true" only if api_url is set AND we can avoid a real call.
# For the basic health probe we don't make any LLM calls — return True iff api_url is configured.
cfg = get_settings()
llm_ok = bool(cfg.llm_api_url)
# Embeddings: True if HashEmbedder works (always does) or if openai provider configured
embeddings_ok = True
if cfg.embeddings_provider == "offline_hash":
try:
embedder = HashEmbedder(dimension=cfg.embeddings_dimension)
_ = await embedder.embed(["ping"])
except Exception:
embeddings_ok = False
status_str = "ok" if (db_ok and qdrant_ok) else "degraded"
return HealthResponse(
status=status_str,
db=db_ok,
qdrant=qdrant_ok,
llm=llm_ok,
embeddings=embeddings_ok,
version=__version__,
)
@router.get("/i18n/{lang}")
async def i18n(lang: str) -> dict:
"""Return translation JSON for the given language.
Backend only knows the en/ru bundles used by the frontend; we serve them
statically from the frontend's `public/i18n/` folder in production, but
this endpoint is useful for hot-reloading in dev.
"""
if lang not in ("en", "ru"):
return {"error": "unsupported language"}
# The frontend owns the bundles; this endpoint returns an empty dict
# (the frontend fetches `/i18n/{lang}.json` as a static asset).
return {"language": lang}

125
app/api/presets.py Normal file
View File

@@ -0,0 +1,125 @@
"""Presets API — CRUD for world presets."""
from __future__ import annotations
import uuid
from fastapi import APIRouter, Depends, HTTPException, Response, status
from sqlalchemy import or_, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.deps import get_current_user
from app.db import get_db
from app.models import User, WorldPreset
from app.schemas import PresetCreateRequest, PresetFull, PresetSummary
router = APIRouter(prefix="/api/presets", tags=["presets"])
@router.get("", response_model=dict)
async def list_presets(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
"""List public presets + current user's presets."""
rows = (
await db.execute(
select(WorldPreset).where(
or_(
WorldPreset.is_public.is_(True),
WorldPreset.owner_id == user.id,
)
).order_by(WorldPreset.created_at.desc())
)
).scalars().all()
return {"items": [PresetSummary.model_validate(r).model_dump() for r in rows]}
@router.post("", response_model=PresetFull, status_code=status.HTTP_201_CREATED)
async def create_preset(
body: PresetCreateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> WorldPreset:
if not user.is_admin:
raise HTTPException(403, "admin_only")
preset = WorldPreset(
owner_id=user.id,
name=body.name,
description=body.description,
language=body.language,
rules=body.rules,
time_schema=body.time_schema,
schemas=body.schemas,
environment_schema=body.environment_schema,
environment_initial=body.environment_initial,
is_public=body.is_public,
status="ready",
)
db.add(preset)
await db.commit()
await db.refresh(preset)
return preset
@router.get("/{preset_id}", response_model=PresetFull)
async def get_preset(
preset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> WorldPreset:
preset = (
await db.execute(select(WorldPreset).where(WorldPreset.id == preset_id))
).scalar_one_or_none()
if preset is None:
raise HTTPException(404, "not_found")
if not preset.is_public and preset.owner_id != user.id and not user.is_admin:
raise HTTPException(403, "not_accessible")
return preset
@router.patch("/{preset_id}", response_model=PresetFull)
async def update_preset(
preset_id: uuid.UUID,
body: PresetCreateRequest,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> WorldPreset:
preset = (
await db.execute(select(WorldPreset).where(WorldPreset.id == preset_id))
).scalar_one_or_none()
if preset is None:
raise HTTPException(404, "not_found")
if preset.owner_id != user.id and not user.is_admin:
raise HTTPException(403, "not_owner")
preset.name = body.name
preset.description = body.description
preset.language = body.language
preset.rules = body.rules
preset.time_schema = body.time_schema
preset.schemas = body.schemas
preset.environment_schema = body.environment_schema
preset.environment_initial = body.environment_initial
preset.is_public = body.is_public
preset.version += 1
await db.commit()
await db.refresh(preset)
return preset
@router.delete("/{preset_id}", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def delete_preset(
preset_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> Response:
preset = (
await db.execute(select(WorldPreset).where(WorldPreset.id == preset_id))
).scalar_one_or_none()
if preset is None:
raise HTTPException(404, "not_found")
if preset.owner_id != user.id and not user.is_admin:
raise HTTPException(403, "not_owner")
preset.status = "archived"
await db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)

348
app/api/sessions.py Normal file
View File

@@ -0,0 +1,348 @@
"""Sessions API — state retrieval, orchestrator iterate stream, world_builder/editor streams, retry/rollback."""
from __future__ import annotations
import json
import urllib.parse
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Response, status
from fastapi.responses import StreamingResponse
from sqlalchemy import 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.db import get_db
from app.engine.game_master import run_iteration
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 Entity, Step, World, WorldPreset
from app.schemas import AnswerRequest, IterateRequest
_logger = get_logger(__name__)
router = APIRouter(prefix="/api/sessions", tags=["sessions"])
def _llm_factory(settings: dict) -> LlmClient | MockLlmClient:
api_url = settings.get("llm.api_url", "")
if not api_url:
return MockLlmClient()
return LlmClient.from_settings(settings)
async def _load_world(db: AsyncSession, world_id: uuid.UUID, 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
# --------------------------------------------------------------------------- #
# State retrieval
# --------------------------------------------------------------------------- #
@router.get("/worlds/{world_id}/state")
async def get_state(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
) -> dict:
"""Return current session state for the play page."""
world = await _load_world(db, world_id, user)
recent = (
await db.execute(
select(Step)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(10)
)
).scalars().all()
recent_steps = [
{
"id": str(s.id), "sequence_number": s.sequence_number,
"player_action": s.player_action, "scene_text": s.scene_text,
"suggested_actions": s.suggested_actions, "created_at": s.created_at.isoformat(),
}
for s in reversed(recent)
]
next_actions = recent_steps[-1]["suggested_actions"] if recent_steps else []
if world.intro_scene and not recent_steps:
next_actions = []
return {
"world": {
"id": str(world.id), "name": world.name, "current_time": world.current_time,
"language": world.language, "intro_scene": world.intro_scene,
},
"environment": world.environment,
"recent_steps": recent_steps,
"next_actions": next_actions,
}
# --------------------------------------------------------------------------- #
# World builder stream (SSE)
# --------------------------------------------------------------------------- #
@router.get("/worlds/{world_id}/builder/stream")
async def builder_stream(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> StreamingResponse:
world = await _load_world(db, world_id, user)
preset: WorldPreset | None = None
if world.preset_id:
preset = (
await db.execute(select(WorldPreset).where(WorldPreset.id == world.preset_id))
).scalar_one_or_none()
emitter = SseEmitter()
player_name = (world.environment or {}).get("player", {}).get("name", "Hero")
notes = world.description
llm = _llm_factory(settings)
async def run_bg():
async with _session_scope() as bg_db:
# Reload world in this session
bg_world = (
await bg_db.execute(select(World).where(World.id == world.id))
).scalar_one()
await run_world_builder(
db=bg_db, world=bg_world, player_name=player_name, notes=notes,
llm=llm, sse=emitter, preset=preset,
)
import asyncio
task = asyncio.create_task(run_bg())
async def gen():
try:
async for evt in emitter.stream():
yield _format_sse(evt)
finally:
await task
return StreamingResponse(
gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# --------------------------------------------------------------------------- #
# World editor stream (SSE)
# --------------------------------------------------------------------------- #
@router.get("/worlds/{world_id}/editor/stream")
async def editor_stream(
world_id: uuid.UUID,
instruction: str = Query(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> StreamingResponse:
world = await _load_world(db, world_id, user)
emitter = SseEmitter()
llm = _llm_factory(settings)
async def run_bg():
async with _session_scope() as bg_db:
bg_world = (
await bg_db.execute(select(World).where(World.id == world.id))
).scalar_one()
await run_world_editor(
db=bg_db, world=bg_world, instruction=instruction, llm=llm, sse=emitter,
)
import asyncio
task = asyncio.create_task(run_bg())
async def gen():
try:
async for evt in emitter.stream():
yield _format_sse(evt)
finally:
await task
return StreamingResponse(
gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# --------------------------------------------------------------------------- #
# Orchestrator iterate
# --------------------------------------------------------------------------- #
@router.post("/worlds/{world_id}/iterate", response_model=dict, status_code=status.HTTP_202_ACCEPTED)
async def iterate(
world_id: uuid.UUID,
body: IterateRequest,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> dict:
world = await _load_world(db, world_id, user)
if world.status != "ready":
raise HTTPException(422, "world_not_ready")
# Compute next sequence number
last_seq = (
await db.execute(
select(Step.sequence_number)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(1)
)
).scalar_one_or_none()
next_seq = (last_seq or 0) + 1
step = Step(
world_id=world.id,
sequence_number=next_seq,
player_action=body.action,
status="pending",
)
db.add(step)
await db.commit()
await db.refresh(step)
return {
"stream_url": f"/api/sessions/worlds/{world.id}/iterate/stream?step_id={step.id}",
"step_id": str(step.id),
}
@router.get("/worlds/{world_id}/iterate/stream")
async def iterate_stream(
world_id: uuid.UUID,
step_id: uuid.UUID = Query(...),
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
settings: dict = Depends(get_settings_dict),
) -> StreamingResponse:
world = await _load_world(db, world_id, user)
step = (
await db.execute(select(Step).where(Step.id == step_id, Step.world_id == world.id))
).scalar_one_or_none()
if step is None:
raise HTTPException(404, "step not found")
emitter = SseEmitter()
llm = _llm_factory(settings)
async def run_bg():
async with _session_scope() as bg_db:
bg_world = (
await bg_db.execute(select(World).where(World.id == world.id))
).scalar_one()
bg_step = (
await bg_db.execute(select(Step).where(Step.id == step.id))
).scalar_one()
await run_iteration(db=bg_db, world=bg_world, step=bg_step, llm=llm, sse=emitter)
import asyncio
task = asyncio.create_task(run_bg())
async def gen():
try:
async for evt in emitter.stream():
yield _format_sse(evt)
finally:
await task
return StreamingResponse(
gen(), media_type="text/event-stream",
headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"},
)
# --------------------------------------------------------------------------- #
# Retry / rollback
# --------------------------------------------------------------------------- #
@router.post("/worlds/{world_id}/retry", response_model=dict)
async def retry_last(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
) -> dict:
"""Soft-delete the last step and create a new one with the same action."""
world = await _load_world(db, world_id, user)
last = (
await db.execute(
select(Step)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(1)
)
).scalar_one_or_none()
if last is None:
raise HTTPException(404, "no_step_to_retry")
last.deleted_at = datetime.now(timezone.utc)
new_step = Step(
world_id=world.id,
sequence_number=last.sequence_number + 1,
player_action=last.player_action,
status="pending",
)
db.add(new_step)
await db.commit()
await db.refresh(new_step)
return {
"step_id": str(new_step.id),
"stream_url": f"/api/sessions/worlds/{world.id}/iterate/stream?step_id={new_step.id}",
}
@router.post("/worlds/{world_id}/rollback", status_code=status.HTTP_204_NO_CONTENT, response_class=Response)
async def rollback_last(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
user=Depends(get_current_user),
) -> Response:
"""Soft-delete the last step."""
world = await _load_world(db, world_id, user)
last = (
await db.execute(
select(Step)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(1)
)
).scalar_one_or_none()
if last is None:
raise HTTPException(404, "no_step_to_rollback")
last.deleted_at = datetime.now(timezone.utc)
await db.commit()
return Response(status_code=status.HTTP_204_NO_CONTENT)
# --------------------------------------------------------------------------- #
# Helpers
# --------------------------------------------------------------------------- #
def _format_sse(evt: dict[str, str]) -> str:
"""Format an SSE event dict into the wire format."""
lines = []
if "id" in evt:
lines.append(f"id: {evt['id']}")
if "event" in evt:
lines.append(f"event: {evt['event']}")
if "data" in evt:
# Split multi-line data
for chunk in evt["data"].split("\n"):
lines.append(f"data: {chunk}")
lines.append("")
lines.append("")
return "\n".join(lines)
async def _session_scope():
"""Open a fresh DB session for the background task."""
from app.db import get_sessionmaker
sm = get_sessionmaker()
async with sm() as s:
yield s

182
app/api/worlds.py Normal file
View File

@@ -0,0 +1,182 @@
"""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

125
app/config.py Normal file
View File

@@ -0,0 +1,125 @@
"""Application configuration loaded from environment variables / settings DB.
Settings are layered:
1. Defaults defined in this module.
2. Overrides from environment variables (or `.env` file).
3. Runtime overrides from the `settings` table (loaded on startup and cached).
The Settings class below is a Pydantic-Settings model — it only handles (1) and (2).
The runtime DB overrides are managed by `app.core.settings_service`.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
"""Layered configuration for the AI-RPG backend."""
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# === Application ===
app_name: str = "AI-RPG"
app_version: str = "1.0.0"
debug: bool = False
log_level: str = "INFO"
secret_key: str = "change-me-in-production-please-32-bytes-long"
jwt_algorithm: str = "HS256"
access_token_expire_minutes: int = 60 * 24 # 24 hours
refresh_token_expire_minutes: int = 60 * 24 * 7 # 7 days
cors_origins: list[str] = Field(default_factory=lambda: ["*"])
# === Admin setup ===
admin_setup_token: str = "" # if empty, will be auto-generated and stored in DB
# === Database ===
database_url: str = "postgresql+asyncpg://airpg:airpg@localhost:5432/airpg"
database_url_sync: str = "postgresql+psycopg2://airpg:airpg@localhost:5432/airpg"
db_pool_size: int = 10
db_max_overflow: int = 20
db_echo: bool = False
# === Qdrant ===
qdrant_url: str = "http://localhost:6333"
qdrant_api_key: str = ""
qdrant_collection_prefix: str = ""
qdrant_timeout: float = 30.0
# === LLM (defaults; runtime overrides in `settings` table) ===
llm_api_url: str = "http://localhost:11434/v1"
llm_api_key: str = ""
llm_model: str = "qwen2.5-7b-instruct"
llm_temperature_orchestrator: float = 0.7
llm_temperature_writer: float = 0.85
llm_max_tokens: int = 2048
llm_timeout_seconds: int = 60
# === Embeddings ===
embeddings_provider: str = "offline_hash" # "offline_hash" | "openai"
embeddings_api_url: str = ""
embeddings_api_key: str = ""
embeddings_model: str = "text-embedding-3-small"
embeddings_dimension: int = 256 # for offline_hash; will be probed for openai
embeddings_timeout_seconds: int = 30
embeddings_batch_size: int = 32
embeddings_cache_ttl_seconds: int = 300
embeddings_max_text_chars: int = 4000
# === Context manager ===
context_guaranteed_messages: int = 10
context_compression_threshold_messages: int = 20
context_compression_threshold_tokens: int = 6000
context_scene_text_truncate_tokens: int = 500
context_auto_rag_on_entity_mention: bool = False
context_safety_margin_tokens: int = 500
llm_context_window_tokens: int = 8192 # for tokenizer-based budgeting
# === Game ===
game_deferred_triggers_enabled: bool = True
game_max_substeps_per_iteration: int = 8
game_max_suggested_actions: int = 3
# === UI ===
ui_page_title: str = "AI-RPG"
ui_favicon_url: str = "/icon.png"
ui_logo_url: str = "/icon.png"
ui_og_image_url: str = ""
# === Storage ===
data_dir: str = "/home/z/my-project/ai-rpg/data"
assets_dir: str = "" # computed in __init__
max_upload_size_bytes: int = 1024 * 1024 # 1 MB
def __init__(self, **values):
super().__init__(**values)
if not self.assets_dir:
self.assets_dir = str(Path(self.data_dir) / "assets")
@field_validator("cors_origins", mode="before")
@classmethod
def _split_cors(cls, v):
if isinstance(v, str):
return [item.strip() for item in v.split(",") if item.strip()]
return v
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Cached settings instance. Use as the single source of truth for env config."""
return Settings()
def reload_settings() -> Settings:
"""Force reload settings (used in tests)."""
get_settings.cache_clear()
return get_settings()

1
app/core/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Empty package marker."""

153
app/core/embeddings.py Normal file
View File

@@ -0,0 +1,153 @@
"""Embedders for RAG.
Two implementations:
- `HashEmbedder`: offline, deterministic bag-of-words + hash projection. Used for dev/test.
- `OpenAIEmbedder`: calls an OpenAI-compatible embeddings API at runtime.
The active embedder is chosen via `settings.embeddings.provider`.
"""
from __future__ import annotations
import hashlib
import math
import re
from collections import Counter
from typing import Protocol, runtime_checkable
import httpx
from app.core.logging import get_logger
_logger = get_logger(__name__)
_WORD_RE = re.compile(r"\w+", re.UNICODE)
def _tokenize(text: str) -> list[str]:
return [w.lower() for w in _WORD_RE.findall(text)]
@runtime_checkable
class Embedder(Protocol):
async def embed(self, texts: list[str]) -> list[list[float]]: ...
@property
def dimension(self) -> int: ...
class HashEmbedder:
"""Offline bag-of-words embedder with hash projection.
Not semantically meaningful, but deterministic and fast — sufficient for
integration tests and local dev. Cosine similarity is non-zero only when
texts share tokens.
"""
def __init__(self, dimension: int = 256):
if dimension <= 0:
raise ValueError("dimension must be positive")
self._dim = dimension
@property
def dimension(self) -> int:
return self._dim
async def embed(self, texts: list[str]) -> list[list[float]]:
out: list[list[float]] = []
for text in texts:
out.append(self._hash_project(text))
return out
def _hash_project(self, text: str) -> list[float]:
vec = [0.0] * self._dim
tokens = _tokenize(text)
if not tokens:
return vec
counts = Counter(tokens)
for token, count in counts.items():
h = hashlib.md5(token.encode("utf-8")).digest()
# Use first 4 bytes for index, next 4 bytes for sign
idx = int.from_bytes(h[:4], "little") % self._dim
sign = 1.0 if (h[4] & 1) == 0 else -1.0
vec[idx] += sign * math.sqrt(count)
# L2 normalize
norm = math.sqrt(sum(v * v for v in vec))
if norm > 0:
vec = [v / norm for v in vec]
return vec
class OpenAIEmbedder:
"""OpenAI-compatible embeddings API client."""
def __init__(
self,
api_url: str,
api_key: str,
model: str,
dimension: int,
timeout: float = 30.0,
batch_size: int = 32,
):
self._api_url = api_url.rstrip("/")
self._api_key = api_key
self._model = model
self._dim = dimension
self._timeout = timeout
self._batch_size = batch_size
@property
def dimension(self) -> int:
return self._dim
async def embed(self, texts: list[str]) -> list[list[float]]:
if not texts:
return []
out: list[list[float]] = []
async with httpx.AsyncClient(timeout=self._timeout) as client:
for i in range(0, len(texts), self._batch_size):
batch = texts[i : i + self._batch_size]
resp = await client.post(
f"{self._api_url}/embeddings",
headers={"Authorization": f"Bearer {self._api_key}"},
json={"model": self._model, "input": batch},
)
resp.raise_for_status()
data = resp.json()
# Sort by index to preserve order
sorted_data = sorted(data["data"], key=lambda x: x["index"])
out.extend(d["embedding"] for d in sorted_data)
return out
async def probe_dimension(self, sample_text: str = "hello world") -> int:
"""Make a single embedding call and return the dimension of the result.
Useful for the "auto-probe dimension" admin button.
"""
result = await self.embed([sample_text])
if not result:
raise RuntimeError("Empty embeddings response")
return len(result[0])
def build_hash_embedder(dimension: int) -> HashEmbedder:
return HashEmbedder(dimension=dimension)
def build_openai_embedder(
api_url: str,
api_key: str,
model: str,
dimension: int,
timeout: float = 30.0,
batch_size: int = 32,
) -> OpenAIEmbedder:
return OpenAIEmbedder(
api_url=api_url,
api_key=api_key,
model=model,
dimension=dimension,
timeout=timeout,
batch_size=batch_size,
)

462
app/core/llm.py Normal file
View File

@@ -0,0 +1,462 @@
"""LLM client — OpenAI-compatible API wrapper with retry, logging, and streaming.
Usage:
client = LlmClient.from_settings(settings_dict)
resp = await client.complete(
stage="orchestrator_phase1",
messages=[{"role": "system", "content": "..."}, ...],
tools=[...], # optional
temperature=0.7,
max_tokens=2048,
stream=False, # if True, returns an async iterator of deltas
user_id=..., world_id=..., step_id=...,
)
"""
from __future__ import annotations
import asyncio
import json
import time
import uuid
from collections.abc import AsyncIterator
from typing import Any
import httpx
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.models import LlmCallLog
_logger = get_logger(__name__)
class LLMError(Exception):
"""Base LLM error."""
def __init__(self, code: str, message: str, status: str = "api_error"):
super().__init__(message)
self.code = code
self.status = status
class LLMTimeoutError(LLMError):
def __init__(self, message: str = "LLM call timed out"):
super().__init__("llm_timeout", message, status="timeout")
class LLMUnavailableError(LLMError):
def __init__(self, message: str = "LLM provider unavailable"):
super().__init__("llm_unavailable", message, status="api_error")
class LLMResponseError(LLMError):
def __init__(self, message: str, code: str = "parse_error"):
super().__init__(code, message, status="parse_error")
class LlmClient:
"""OpenAI-compatible LLM client with retry, logging, and streaming."""
def __init__(
self,
api_url: str,
api_key: str,
model: str,
timeout: float = 60.0,
max_retries: int = 3,
):
self._api_url = api_url.rstrip("/")
self._api_key = api_key
self._model = model
self._timeout = timeout
self._max_retries = max_retries
# ------------------------------------------------------------------ #
# Construction
# ------------------------------------------------------------------ #
@classmethod
def from_settings(cls, settings: dict[str, Any]) -> "LlmClient":
return cls(
api_url=settings.get("llm.api_url", "http://localhost:11434/v1"),
api_key=settings.get("llm.api_key", ""),
model=settings.get("llm.model", "qwen2.5-7b-instruct"),
timeout=float(settings.get("llm.timeout_seconds", 60)),
)
# ------------------------------------------------------------------ #
# Non-streaming call
# ------------------------------------------------------------------ #
async def complete(
self,
*,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None = None,
tool_choice: Any = None,
temperature: float = 0.7,
top_p: float = 0.9,
max_tokens: int = 2048,
user_id: uuid.UUID | None = None,
world_id: uuid.UUID | None = None,
step_id: uuid.UUID | None = None,
session: AsyncSession | None = None,
stream: bool = False,
) -> dict[str, Any]:
"""Make a non-streaming chat completion call.
Returns a dict with keys:
- `message`: assistant message (with `content` and optional `tool_calls`)
- `finish_reason`: stop | length | tool_calls
- `prompt_tokens`, `completion_tokens`, `latency_ms`
- `log_id`: id of the LlmCallLog row if `session` provided
"""
if stream:
raise ValueError("Use stream_complete() for streaming calls")
payload: dict[str, Any] = {
"model": self._model,
"messages": messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": False,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
start = time.monotonic()
last_exc: Exception | None = None
for attempt in range(self._max_retries):
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
resp = await client.post(
f"{self._api_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json=payload,
)
if resp.status_code >= 500:
raise LLMUnavailableError(
f"LLM provider returned {resp.status_code}: {resp.text[:200]}"
)
if resp.status_code == 429:
raise LLMUnavailableError("LLM provider rate-limited (429)")
if resp.status_code >= 400:
raise LLMResponseError(
f"LLM provider returned {resp.status_code}: {resp.text[:500]}",
code="api_error",
)
data = resp.json()
break
except (httpx.TimeoutException, asyncio.TimeoutError) as e:
last_exc = LLMTimeoutError(str(e))
_logger.warning(
"llm_timeout", stage=stage, attempt=attempt + 1, error=str(e)
)
except (httpx.ConnectError, httpx.NetworkError) as e:
last_exc = LLMUnavailableError(str(e))
_logger.warning(
"llm_connection_error", stage=stage, attempt=attempt + 1, error=str(e)
)
except LLMError as e:
last_exc = e
_logger.warning(
"llm_error", stage=stage, attempt=attempt + 1, error=str(e)
)
# exponential backoff
await asyncio.sleep(min(2**attempt, 4))
else:
# All retries exhausted
if session is not None:
await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message={},
tool_calls=None,
prompt_tokens=None,
completion_tokens=None,
latency_ms=int((time.monotonic() - start) * 1000),
temperature=temperature,
status=last_exc.status if isinstance(last_exc, LLMError) else "api_error",
error_message=str(last_exc) if last_exc else "unknown",
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
assert last_exc is not None
raise last_exc
latency_ms = int((time.monotonic() - start) * 1000)
choice = data["choices"][0]
msg = choice.get("message", {})
finish_reason = choice.get("finish_reason", "stop")
usage = data.get("usage", {})
log_id: uuid.UUID | None = None
if session is not None:
log_id = await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message=msg,
tool_calls=msg.get("tool_calls"),
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency_ms,
temperature=temperature,
status="ok",
error_message=None,
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
return {
"message": msg,
"finish_reason": finish_reason,
"prompt_tokens": usage.get("prompt_tokens"),
"completion_tokens": usage.get("completion_tokens"),
"latency_ms": latency_ms,
"log_id": log_id,
}
# ------------------------------------------------------------------ #
# Streaming call
# ------------------------------------------------------------------ #
async def stream_complete(
self,
*,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None = None,
tool_choice: Any = None,
temperature: float = 0.85,
top_p: float = 0.95,
max_tokens: int = 2048,
user_id: uuid.UUID | None = None,
world_id: uuid.UUID | None = None,
step_id: uuid.UUID | None = None,
session: AsyncSession | None = None,
) -> AsyncIterator[dict[str, Any]]:
"""Stream chat completion. Yields dicts with keys:
- `delta`: {content?, tool_calls?}
- `finish_reason`: present only on the final chunk
After the iterator is exhausted, the call is logged to `llm_call_logs`.
"""
payload: dict[str, Any] = {
"model": self._model,
"messages": messages,
"temperature": temperature,
"top_p": top_p,
"max_tokens": max_tokens,
"stream": True,
}
if tools:
payload["tools"] = tools
payload["tool_choice"] = tool_choice or "auto"
start = time.monotonic()
full_content_parts: list[str] = []
full_tool_calls: list[dict] = []
finish_reason: str | None = None
usage: dict[str, Any] = {}
status = "ok"
error_message: str | None = None
try:
async with httpx.AsyncClient(timeout=self._timeout) as client:
async with client.stream(
"POST",
f"{self._api_url}/chat/completions",
headers={
"Authorization": f"Bearer {self._api_key}",
"Content-Type": "application/json",
},
json=payload,
) as resp:
if resp.status_code >= 400:
body = await resp.aread()
raise LLMResponseError(
f"LLM provider returned {resp.status_code}: {body.decode('utf-8', 'ignore')[:500]}",
code="api_error",
)
async for line in resp.aiter_lines():
if not line:
continue
if line.startswith("data: "):
line = line[6:]
if line.strip() == "[DONE]":
break
try:
chunk = json.loads(line)
except json.JSONDecodeError:
continue
if not chunk.get("choices"):
if chunk.get("usage"):
usage = chunk["usage"]
continue
choice = chunk["choices"][0]
delta = choice.get("delta", {})
if delta.get("content"):
full_content_parts.append(delta["content"])
if delta.get("tool_calls"):
full_tool_calls.extend(delta["tool_calls"])
if choice.get("finish_reason"):
finish_reason = choice["finish_reason"]
yield {"delta": delta, "finish_reason": finish_reason}
except Exception as e:
status = "api_error" if not isinstance(e, LLMTimeoutError) else "timeout"
error_message = str(e)
_logger.warning("llm_stream_error", stage=stage, error=error_message)
raise
finally:
latency_ms = int((time.monotonic() - start) * 1000)
if session is not None:
full_content = "".join(full_content_parts)
await self._write_log_safely(
session=session,
stage=stage,
messages=messages,
tools=tools,
response_message={
"role": "assistant",
"content": full_content,
"tool_calls": full_tool_calls or None,
},
tool_calls=full_tool_calls or None,
prompt_tokens=usage.get("prompt_tokens"),
completion_tokens=usage.get("completion_tokens"),
latency_ms=latency_ms,
temperature=temperature,
status=status,
error_message=error_message,
user_id=user_id,
world_id=world_id,
step_id=step_id,
)
# ------------------------------------------------------------------ #
# Safe logging (separate transaction)
# ------------------------------------------------------------------ #
async def _write_log_safely(
self,
*,
session: AsyncSession,
stage: str,
messages: list[dict[str, Any]],
tools: list[dict] | None,
response_message: dict[str, Any],
tool_calls: list | None,
prompt_tokens: int | None,
completion_tokens: int | None,
latency_ms: int,
temperature: float,
status: str,
error_message: str | None,
user_id: uuid.UUID | None,
world_id: uuid.UUID | None,
step_id: uuid.UUID | None,
) -> uuid.UUID | None:
"""Insert an LlmCallLog row in a nested transaction so it survives rollback.
Errors here are logged but never raised — logging is best-effort.
"""
try:
async with session.begin_nested():
log = LlmCallLog(
user_id=user_id,
world_id=world_id,
step_id=step_id,
stage=stage,
model=self._model,
request_messages=messages,
request_tools=tools,
response_message=response_message,
tool_calls=tool_calls,
prompt_tokens=prompt_tokens,
completion_tokens=completion_tokens,
latency_ms=latency_ms,
temperature=temperature,
status=status,
error_message=error_message,
)
session.add(log)
await session.flush()
log_id = log.id
await session.commit()
return log_id
except Exception as e: # noqa: BLE001
_logger.error("llm_log_write_failed", stage=stage, error=str(e))
try:
await session.rollback()
except Exception:
pass
return None
# --------------------------------------------------------------------------- #
# Mock LLM client (for tests)
# --------------------------------------------------------------------------- #
class MockLlmClient:
"""Replay-based mock LLM client. Returns pre-recorded responses per stage."""
def __init__(self, replay_data: dict[str, list[dict]] | None = None):
self._replay = replay_data or {}
self._call_counts: dict[str, int] = {}
# Allow recording mode
self.recorded_calls: list[dict[str, Any]] = []
def set_replay(self, stage: str, responses: list[dict]) -> None:
self._replay[stage] = responses
self._call_counts.pop(stage, None)
async def complete(self, *, stage: str, messages=None, tools=None, **kwargs) -> dict[str, Any]:
idx = self._call_counts.get(stage, 0)
responses = self._replay.get(stage, [])
if idx >= len(responses):
raise LLMResponseError(
f"Replay exhausted for stage {stage} (call #{idx + 1})",
code="replay_exhausted",
)
resp = responses[idx]
self._call_counts[stage] = idx + 1
self.recorded_calls.append({"stage": stage, "messages": messages, "tools": tools})
# Mimic the real client's return shape
return {
"message": resp.get("message", {"role": "assistant", "content": resp.get("content", "")}),
"finish_reason": resp.get("finish_reason", "stop"),
"prompt_tokens": resp.get("prompt_tokens", 0),
"completion_tokens": resp.get("completion_tokens", 0),
"latency_ms": 0,
"log_id": None,
}
async def stream_complete(self, *, stage: str, messages=None, tools=None, **kwargs):
idx = self._call_counts.get(stage, 0)
responses = self._replay.get(stage, [])
if idx >= len(responses):
raise LLMResponseError(
f"Replay exhausted for stage {stage} (call #{idx + 1})",
code="replay_exhausted",
)
resp = responses[idx]
self._call_counts[stage] = idx + 1
content = resp.get("message", {}).get("content", resp.get("content", ""))
# Yield content in 3 chunks for streaming tests
chunk_size = max(1, len(content) // 3)
for i in range(0, len(content), chunk_size):
yield {"delta": {"content": content[i : i + chunk_size]}, "finish_reason": None}
yield {"delta": {}, "finish_reason": "stop"}
def get_mock_client() -> MockLlmClient:
"""Convenience factory — used in tests and as a fallback in dev when no LLM configured."""
return MockLlmClient()

48
app/core/logging.py Normal file
View File

@@ -0,0 +1,48 @@
"""Application logging setup using structlog."""
from __future__ import annotations
import logging
import sys
import structlog
from app.config import get_settings
def configure_logging() -> None:
"""Configure structlog + stdlib logging once at startup."""
cfg = get_settings()
level = getattr(logging, cfg.log_level.upper(), logging.INFO)
# stdlib root logger
logging.basicConfig(
level=level,
format="%(message)s",
stream=sys.stdout,
)
# structlog processors — JSON output in prod, pretty console in dev
shared_processors = [
structlog.contextvars.merge_contextvars,
structlog.processors.add_log_level,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
]
if cfg.debug:
renderer = structlog.dev.ConsoleRenderer(colors=True)
else:
renderer = structlog.processors.JSONRenderer()
structlog.configure(
processors=shared_processors + [renderer],
wrapper_class=structlog.make_filtering_bound_logger(level),
logger_factory=structlog.PrintLoggerFactory(),
cache_logger_on_first_use=True,
)
def get_logger(name: str | None = None) -> structlog.stdlib.BoundLogger:
"""Return a structlog logger bound to `name`."""
return structlog.get_logger(name) # type: ignore[return-value]

130
app/core/qdrant_client.py Normal file
View File

@@ -0,0 +1,130 @@
"""Qdrant client wrapper (singleton) with health check."""
from __future__ import annotations
from typing import Any
from qdrant_client import AsyncQdrantClient
from qdrant_client.http.models import (
Distance,
PayloadSchemaType,
VectorParams,
)
from app.config import get_settings
from app.core.logging import get_logger
_logger = get_logger(__name__)
_client: AsyncQdrantClient | None = None
def get_qdrant_client() -> AsyncQdrantClient:
"""Return the singleton AsyncQdrantClient."""
global _client
if _client is None:
cfg = get_settings()
_client = AsyncQdrantClient(
url=cfg.qdrant_url,
api_key=cfg.qdrant_api_key or None,
timeout=cfg.qdrant_timeout,
)
return _client
async def dispose_qdrant_client() -> None:
"""Close the Qdrant client (on shutdown)."""
global _client
if _client is not None:
try:
await _client.close()
except Exception as e: # noqa: BLE001
_logger.warning("qdrant_close_failed", error=str(e))
_client = None
async def ping_qdrant() -> bool:
"""Health-check: returns True if Qdrant responds."""
try:
client = get_qdrant_client()
await client.get_collections()
return True
except Exception as e: # noqa: BLE001
_logger.warning("qdrant_ping_failed", error=str(e))
return False
async def init_qdrant_collections(dimension: int) -> dict[str, Any]:
"""Create collections `entities` and `story_entries` if missing.
Returns a dict with the list of created collection names and the dimension used.
"""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
client = get_qdrant_client()
existing = {c.name for c in (await client.get_collections()).collections}
created: list[str] = []
collections_config = {
f"{prefix}entities": [
("world_id", PayloadSchemaType.KEYWORD),
("entity_type", PayloadSchemaType.KEYWORD),
("deleted", PayloadSchemaType.BOOL),
],
f"{prefix}story_entries": [
("world_id", PayloadSchemaType.KEYWORD),
("entry_type", PayloadSchemaType.KEYWORD),
("created_at", PayloadSchemaType.INTEGER),
],
}
for name, indexes in collections_config.items():
if name in existing:
continue
await client.create_collection(
collection_name=name,
vectors_config=VectorParams(size=dimension, distance=Distance.COSINE),
)
for field, schema_type in indexes:
await client.create_payload_index(name, field, schema_type)
created.append(name)
_logger.info("qdrant_collection_created", name=name, dimension=dimension)
return {"created": created, "dimension": dimension, "existing": sorted(existing)}
async def cleanup_world_points(world_id: str) -> None:
"""Best-effort delete of all Qdrant points for a given world_id."""
from qdrant_client.http.models import (
FieldCondition,
Filter,
FilterSelector,
MatchValue,
)
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
client = get_qdrant_client()
for collection in (f"{prefix}entities", f"{prefix}story_entries"):
try:
await client.delete(
collection_name=collection,
points_selector=FilterSelector(
filter=Filter(
must=[
FieldCondition(
key="world_id",
match=MatchValue(value=str(world_id)),
)
]
)
),
)
except Exception as e: # noqa: BLE001
_logger.error(
"qdrant_cleanup_failed",
collection=collection,
world_id=str(world_id),
error=str(e),
)

326
app/core/rag.py Normal file
View File

@@ -0,0 +1,326 @@
"""RAG — retrieval-augmented generation through Qdrant + PostgreSQL.
Two-stage retrieval:
1. Vector search in Qdrant (filtered by world_id).
2. Hydrate full entity/story-entry data from PostgreSQL by IDs.
"""
from __future__ import annotations
import time
import uuid
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.core.embeddings import (
HashEmbedder,
OpenAIEmbedder,
build_hash_embedder,
build_openai_embedder,
)
from app.core.logging import get_logger
from app.core.qdrant_client import get_qdrant_client
from app.models import Entity, StoryEntry
_logger = get_logger(__name__)
_embedder_cache: dict[str, Any] = {}
async def get_embedder():
"""Return the active Embedder based on settings.
Falls back to HashEmbedder if the OpenAI embedder cannot be built.
"""
from app.core.settings_service import get_all_settings
# We can't take a DB session here — use a module-level cache.
# On settings change the admin should hit "test embeddings" which clears the cache.
if "active" in _embedder_cache:
return _embedder_cache["active"]
cfg = get_settings()
provider = cfg.embeddings_provider
if provider == "offline_hash":
emb = build_hash_embedder(cfg.embeddings_dimension)
elif provider == "openai":
api_url = cfg.embeddings_api_url or cfg.llm_api_url
api_key = cfg.embeddings_api_key or cfg.llm_api_key
if not api_url:
_logger.warning("embeddings_openai_no_url_fallback_hash")
emb = build_hash_embedder(cfg.embeddings_dimension)
else:
emb = build_openai_embedder(
api_url=api_url,
api_key=api_key,
model=cfg.embeddings_model,
dimension=cfg.embeddings_dimension,
timeout=float(cfg.embeddings_timeout_seconds),
batch_size=cfg.embeddings_batch_size,
)
else:
_logger.warning("embeddings_unknown_provider_fallback_hash", provider=provider)
emb = build_hash_embedder(cfg.embeddings_dimension)
_embedder_cache["active"] = emb
return emb
def reset_embedder_cache() -> None:
"""Clear the cached embedder (used by admin test endpoints after settings change)."""
_embedder_cache.clear()
async def rag_query(
*,
db: AsyncSession,
world_id: uuid.UUID,
query: str,
limit: int = 5,
filter_type: str = "all",
min_score: float = 0.0,
) -> list[dict[str, Any]]:
"""Semantic search over entities + story_entries via Qdrant."""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
embedder = await get_embedder()
try:
vecs = await embedder.embed([query[: cfg.embeddings_max_text_chars]])
if not vecs:
return []
query_vec = vecs[0]
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_embed_failed", error=str(e))
return []
client = get_qdrant_client()
from qdrant_client.http.models import (
FieldCondition,
Filter,
MatchValue,
)
world_filter = FieldCondition(
key="world_id", match=MatchValue(value=str(world_id))
)
raw_results: list[dict[str, Any]] = []
if filter_type in ("all", "entities"):
try:
ents = await client.search(
collection_name=f"{prefix}entities",
query_vector=query_vec,
query_filter=Filter(
must=[
world_filter,
FieldCondition(
key="deleted", match=MatchValue(value=False)
),
]
),
limit=limit,
score_threshold=min_score,
with_payload=True,
)
for p in ents:
raw_results.append({
"type": "entity",
"id": p.payload.get("entity_id"),
"score": float(p.score),
"name": p.payload.get("name"),
"entity_type": p.payload.get("entity_type"),
})
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_entities_failed", error=str(e))
if filter_type in ("all", "story_entries"):
try:
sts = await client.search(
collection_name=f"{prefix}story_entries",
query_vector=query_vec,
query_filter=Filter(must=[world_filter]),
limit=limit,
score_threshold=min_score,
with_payload=True,
)
for p in sts:
raw_results.append({
"type": "story_entry",
"id": p.payload.get("entry_id"),
"score": float(p.score),
"entry_type": p.payload.get("entry_type"),
})
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_stories_failed", error=str(e))
# Sort and truncate
raw_results.sort(key=lambda r: r["score"], reverse=True)
top = raw_results[:limit]
return await _hydrate(db, top, world_id)
async def _hydrate(
db: AsyncSession, items: list[dict[str, Any]], world_id: uuid.UUID
) -> list[dict[str, Any]]:
"""Stage 2: pull full records from PostgreSQL by IDs."""
entity_ids = [uuid.UUID(i["id"]) for i in items if i["type"] == "entity"]
story_ids = [uuid.UUID(i["id"]) for i in items if i["type"] == "story_entry"]
ents_map: dict[uuid.UUID, Entity] = {}
stories_map: dict[uuid.UUID, StoryEntry] = {}
if entity_ids:
rows = (
await db.execute(
select(Entity).where(
Entity.id.in_(entity_ids), Entity.world_id == world_id
)
)
).scalars().all()
ents_map = {r.id: r for r in rows}
if story_ids:
rows = (
await db.execute(
select(StoryEntry).where(
StoryEntry.id.in_(story_ids), StoryEntry.world_id == world_id
)
)
).scalars().all()
stories_map = {r.id: r for r in rows}
out: list[dict[str, Any]] = []
for i in items:
if i["type"] == "entity":
ent = ents_map.get(uuid.UUID(i["id"]))
if ent and ent.deleted_at is None:
out.append({
**i,
"content": {
"entity_type": ent.entity_type,
"name": ent.name,
"data": ent.data,
},
})
else:
se = stories_map.get(uuid.UUID(i["id"]))
if se:
out.append({
**i,
"content": {
"text": se.content,
"entry_type": se.entry_type,
"metadata": se.metadata_,
},
})
return out
async def rag_add(
*,
db: AsyncSession,
world_id: uuid.UUID,
content: str,
entry_type: str,
metadata: dict | None = None,
step_id: uuid.UUID | None = None,
) -> StoryEntry:
"""Add a story entry and index it in Qdrant (best-effort)."""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
entry = StoryEntry(
world_id=world_id,
content=content,
entry_type=entry_type,
metadata_=metadata or {},
embedding_status="pending",
)
db.add(entry)
await db.flush()
try:
embedder = await get_embedder()
vecs = await embedder.embed([content[: cfg.embeddings_max_text_chars]])
if vecs:
point_id = str(entry.id)
from qdrant_client.http.models import PointStruct
await get_qdrant_client().upsert(
collection_name=f"{prefix}story_entries",
points=[
PointStruct(
id=point_id,
vector=vecs[0],
payload={
"world_id": str(world_id),
"entry_id": point_id,
"entry_type": entry_type,
"step_id": str(step_id) if step_id else None,
"created_at": int(time.time()),
},
)
],
)
entry.qdrant_point_id = point_id
entry.embedding_status = "indexed"
except Exception as e: # noqa: BLE001
_logger.warning("rag_add_embed_failed", entry_id=str(entry.id), error=str(e))
entry.embedding_status = "failed"
await db.flush()
return entry
async def index_entity(
*,
db: AsyncSession,
entity: Entity,
) -> None:
"""Index (or re-index) an entity's vector in Qdrant."""
cfg = get_settings()
prefix = cfg.qdrant_collection_prefix or ""
text = entity.name + " " + _stringify(entity.data)
try:
embedder = await get_embedder()
vecs = await embedder.embed([text[: cfg.embeddings_max_text_chars]])
if not vecs:
return
point_id = str(entity.id)
from qdrant_client.http.models import PointStruct
await get_qdrant_client().upsert(
collection_name=f"{prefix}entities",
points=[
PointStruct(
id=point_id,
vector=vecs[0],
payload={
"world_id": str(entity.world_id),
"entity_id": point_id,
"entity_type": entity.entity_type,
"name": entity.name,
"deleted": entity.deleted_at is not None,
},
)
],
)
entity.qdrant_point_id = point_id
entity.embedding_status = "indexed"
except Exception as e: # noqa: BLE001
_logger.warning("entity_index_failed", entity_id=str(entity.id), error=str(e))
entity.embedding_status = "failed"
await db.flush()
def _stringify(obj: Any) -> str:
import json
try:
return json.dumps(obj, ensure_ascii=False, default=str)
except Exception: # noqa: BLE001
return str(obj)

85
app/core/security.py Normal file
View File

@@ -0,0 +1,85 @@
"""Security: JWT creation/verification and password hashing."""
from __future__ import annotations
import uuid
from datetime import datetime, timedelta, timezone
from typing import Any
from jose import JWTError, jwt
from passlib.context import CryptContext
from app.config import get_settings
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
def hash_password(plain: str) -> str:
"""Hash a password using bcrypt."""
return _pwd_context.hash(plain)
def verify_password(plain: str, hashed: str) -> bool:
"""Verify a password against its bcrypt hash."""
try:
return _pwd_context.verify(plain, hashed)
except (ValueError, TypeError):
return False
def create_access_token(
subject: str | uuid.UUID,
extra_claims: dict[str, Any] | None = None,
expires_in_minutes: int | None = None,
) -> str:
"""Create a signed JWT access token."""
cfg = get_settings()
minutes = expires_in_minutes or cfg.access_token_expire_minutes
now = datetime.now(timezone.utc)
payload: dict[str, Any] = {
"sub": str(subject),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=minutes)).timestamp()),
"type": "access",
}
if extra_claims:
payload.update(extra_claims)
return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm)
def create_refresh_token(
subject: str | uuid.UUID, expires_in_minutes: int | None = None
) -> str:
"""Create a signed JWT refresh token."""
cfg = get_settings()
minutes = expires_in_minutes or cfg.refresh_token_expire_minutes
now = datetime.now(timezone.utc)
payload = {
"sub": str(subject),
"iat": int(now.timestamp()),
"exp": int((now + timedelta(minutes=minutes)).timestamp()),
"type": "refresh",
}
return jwt.encode(payload, cfg.secret_key, algorithm=cfg.jwt_algorithm)
def decode_token(token: str) -> dict[str, Any]:
"""Decode and verify a JWT. Raises JWTError on failure."""
cfg = get_settings()
return jwt.decode(token, cfg.secret_key, algorithms=[cfg.jwt_algorithm])
def validate_password_strength(password: str) -> list[str]:
"""Return a list of validation errors (empty list = valid password)."""
errors: list[str] = []
if len(password) < 8:
errors.append("Password must be at least 8 characters long")
if not any(c.isalpha() for c in password):
errors.append("Password must contain at least one letter")
if not any(c.isdigit() for c in password):
errors.append("Password must contain at least one digit")
# Tiny blacklist of trivial passwords
blacklist = {"password", "12345678", "qwerty12", "password1", "abcdefgh"}
if password.lower() in blacklist:
errors.append("Password is too common")
return errors

View File

@@ -0,0 +1,187 @@
"""Settings service — runtime overrides from the `settings` table.
Layered:
1. App config (env vars / .env) — `app.config.get_settings()`
2. DB overrides — `settings` table
3. `get_setting(key)` merges them with DB taking precedence.
"""
from __future__ import annotations
import secrets
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.models import Setting
# Default settings written to DB on first run.
# These match the seed list in `docs/AI-RPG_TZ_TDD.md` §5.2.2.
DEFAULT_SETTINGS: dict[str, dict[str, Any]] = {
"llm.api_url": {"value": None, "description": "OpenAI-compatible endpoint URL"},
"llm.api_key": {"value": "", "description": "API key for LLM (stored as string)"},
"llm.model": {"value": "qwen2.5-7b-instruct", "description": "Chat model name"},
"llm.temperature_orchestrator": {"value": 0.7, "description": "Phase 1 temperature"},
"llm.temperature_writer": {"value": 0.85, "description": "Phase 2 temperature"},
"llm.max_tokens": {"value": 2048, "description": "Max completion tokens"},
"llm.timeout_seconds": {"value": 60, "description": "LLM call timeout"},
"embeddings.provider": {
"value": "offline_hash",
"description": "offline_hash | openai",
},
"embeddings.api_url": {"value": "", "description": "OpenAI-compatible embeddings URL"},
"embeddings.api_key": {"value": "", "description": "API key for embeddings"},
"embeddings.model": {"value": "text-embedding-3-small", "description": "Embedding model"},
"embeddings.dimension": {"value": 256, "description": "Embedding dimension"},
"embeddings.timeout_seconds": {"value": 30, "description": "Embeddings API timeout"},
"embeddings.batch_size": {"value": 32, "description": "Batch size for embeddings API"},
"embeddings.cache_ttl_seconds": {"value": 300, "description": "LRU cache TTL"},
"embeddings.max_text_chars": {"value": 4000, "description": "Text truncation before embedding"},
"context.guaranteed_messages": {"value": 10, "description": "Always-in-context messages"},
"context.compression_threshold_messages": {"value": 20, "description": "Compression threshold"},
"context.compression_threshold_tokens": {"value": 6000, "description": "Token-based threshold"},
"context.scene_text_truncate_tokens": {"value": 500, "description": "scene_text truncation"},
"context.auto_rag_on_entity_mention": {"value": False, "description": "Auto RAG on entity mention"},
"context.safety_margin_tokens": {"value": 500, "description": "Safety margin from edge"},
"qdrant.url": {"value": "http://qdrant:6333", "description": "Qdrant URL"},
"qdrant.api_key": {"value": "", "description": "Qdrant API key"},
"qdrant.collection_prefix": {"value": "", "description": "Collection prefix"},
"game.deferred_triggers_enabled": {"value": True, "description": "Enable deferred triggers"},
"game.max_substeps_per_iteration": {"value": 8, "description": "Max Phase 1 substeps"},
"game.max_suggested_actions": {"value": 3, "description": "Max suggested actions"},
"ui.page_title": {"value": "AI-RPG", "description": "Browser tab title"},
"ui.favicon_url": {"value": "/icon.png", "description": "Favicon URL"},
"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"},
}
# Keys whose values should never be returned to the client in plaintext.
SECRET_KEYS = {"llm.api_key", "embeddings.api_key", "qdrant.api_key", "admin.setup_token"}
# Map: setting key -> (env-var attribute on Settings, default value)
ENV_OVERRIDE_MAP = {
"llm.api_url": ("llm_api_url", None),
"llm.api_key": ("llm_api_key", None),
"llm.model": ("llm_model", None),
"llm.timeout_seconds": ("llm_timeout_seconds", None),
"embeddings.provider": ("embeddings_provider", None),
"embeddings.api_url": ("embeddings_api_url", None),
"embeddings.api_key": ("embeddings_api_key", None),
"embeddings.model": ("embeddings_model", None),
"embeddings.dimension": ("embeddings_dimension", None),
"qdrant.url": ("qdrant_url", None),
"qdrant.api_key": ("qdrant_api_key", None),
"qdrant.collection_prefix": ("qdrant_collection_prefix", None),
"ui.page_title": ("ui_page_title", None),
"ui.favicon_url": ("ui_favicon_url", None),
"ui.logo_url": ("ui_logo_url", None),
}
async def seed_default_settings(session: AsyncSession) -> None:
"""Upsert all DEFAULT_SETTINGS rows. Called on application startup."""
existing = (
await session.execute(select(Setting).where(Setting.key.in_(DEFAULT_SETTINGS.keys())))
).scalars().all()
existing_keys = {row.key for row in existing}
cfg = get_settings()
for key, spec in DEFAULT_SETTINGS.items():
if key in existing_keys:
continue
value = spec["value"]
# Apply env-var override on first seed (so docker-compose env wins).
env_attr = ENV_OVERRIDE_MAP.get(key)
if env_attr is not None and env_attr[1] is None:
env_val = getattr(cfg, env_attr[0], None)
if env_val not in (None, ""):
value = env_val
# Special: admin.setup_token — generate random if env not set
if key == "admin.setup_token" and not value:
env_token = cfg.admin_setup_token
value = env_token if env_token else secrets.token_urlsafe(16)
session.add(
Setting(key=key, value=value, description=spec["description"])
)
await session.commit()
async def get_all_settings(session: AsyncSession) -> dict[str, Any]:
"""Return all settings as a dict (with env overrides applied for missing keys)."""
rows = (await session.execute(select(Setting))).scalars().all()
cfg = get_settings()
out: dict[str, Any] = {}
for key, spec in DEFAULT_SETTINGS.items():
row = next((r for r in rows if r.key == key), None)
if row is not None:
out[key] = row.value
else:
# Fall back to env-var if present, otherwise spec default
env_attr = ENV_OVERRIDE_MAP.get(key)
env_val = (
getattr(cfg, env_attr[0], None)
if env_attr and env_attr[1] is None
else None
)
out[key] = env_val if env_val not in (None, "") else spec["value"]
return out
async def get_setting(session: AsyncSession, key: str) -> Any:
"""Get a single setting by key, with env override fallback."""
row = (
await session.execute(select(Setting).where(Setting.key == key))
).scalar_one_or_none()
if row is not None:
return row.value
# Env-var fallback
env_attr = ENV_OVERRIDE_MAP.get(key)
if env_attr and env_attr[1] is None:
env_val = getattr(get_settings(), env_attr[0], None)
if env_val not in (None, ""):
return env_val
return DEFAULT_SETTINGS.get(key, {}).get("value")
async def set_setting(session: AsyncSession, key: str, value: Any) -> Any:
"""Upsert a setting value. Returns the new value."""
if key not in DEFAULT_SETTINGS:
# Allow ad-hoc keys but warn in logs
import logging
logging.getLogger(__name__).warning("creating_unregistered_setting", extra={"key": key})
row = (
await session.execute(select(Setting).where(Setting.key == key))
).scalar_one_or_none()
if row is None:
row = Setting(
key=key,
value=value,
description=DEFAULT_SETTINGS.get(key, {}).get("description"),
)
session.add(row)
else:
row.value = value
await session.commit()
return value
def mask_secret(key: str, value: Any) -> Any:
"""Mask secret values for safe display in admin UI."""
if key in SECRET_KEYS and isinstance(value, str) and value:
if len(value) <= 4:
return "****"
return value[:2] + "" + "*" * (min(len(value) - 4, 8)) + value[-2:]
return value
async def get_admin_setup_token(session: AsyncSession) -> str:
"""Return the current admin setup token (generating one if absent)."""
token = await get_setting(session, "admin.setup_token")
if not token:
token = secrets.token_urlsafe(16)
await set_setting(session, "admin.setup_token", token)
return token

307
app/core/state_validator.py Normal file
View File

@@ -0,0 +1,307 @@
"""State validator for environment / entity.data / world schema.
All mutations of `world.environment` and `entity.data` go through this module.
The orchestrator's `env_update` and `entity_update` tools use `apply_patch`.
Validation rules:
- Required fields must be present (per `environment_schema` and entity `schemas`).
- Field types must match the declared type.
- Numeric ranges enforced when `max`/`min` provided.
- Nested `object` / `array` schemas are validated recursively.
"""
from __future__ import annotations
import re
from typing import Any
# Supported primitive JSON-schema type names
_PRIMITIVES = {"string", "integer", "number", "boolean"}
_PATCH_OPS = {"set", "inc", "dec", "append", "remove"}
def validate_state(state: dict[str, Any], schema_fields: list[dict]) -> tuple[bool, list[str]]:
"""Validate `state` against a list of field definitions.
Each field definition has the shape:
{
"name": "player",
"type": "object" | "array" | "string" | ...,
"required": bool,
"properties": [ ... ], # for type=object
"items": { ... }, # for type=array
"min": int, "max": int, # for numeric types
"default": <any>
}
"""
errors: list[str] = []
for field in schema_fields:
name = field.get("name")
if not name:
errors.append("Schema field missing 'name'")
continue
if name not in state:
if field.get("required"):
errors.append(f"Missing required field: {name}")
continue
_validate_value(state[name], field, path=name, errors=errors)
return (len(errors) == 0, errors)
def _validate_value(
value: Any, field_schema: dict, path: str, errors: list[str]
) -> None:
ftype = field_schema.get("type", "string")
if ftype in _PRIMITIVES:
_validate_primitive(value, ftype, field_schema, path, errors)
elif ftype == "object":
if not isinstance(value, dict):
errors.append(f"{path} must be object")
return
props = field_schema.get("properties", [])
# validate child fields
ok, child_errors = validate_state(value, props)
if not ok:
errors.extend(child_errors)
elif ftype == "array":
if not isinstance(value, list):
errors.append(f"{path} must be array")
return
items_schema = field_schema.get("items")
if items_schema:
for i, item in enumerate(value):
_validate_value(item, items_schema, f"{path}[{i}]", errors)
else:
errors.append(f"{path}: unknown type {ftype!r}")
def _validate_primitive(
value: Any, ftype: str, field_schema: dict, path: str, errors: list[str]
) -> None:
if ftype == "string":
if not isinstance(value, str):
errors.append(f"{path} must be string")
return
elif ftype == "integer":
if isinstance(value, bool) or not isinstance(value, int):
errors.append(f"{path} must be integer")
return
elif ftype == "number":
if isinstance(value, bool) or not isinstance(value, (int, float)):
errors.append(f"{path} must be number")
return
elif ftype == "boolean":
if not isinstance(value, bool):
errors.append(f"{path} must be boolean")
return
# Range checks
if ftype in ("integer", "number"):
mn = field_schema.get("min")
mx = field_schema.get("max")
if mn is not None and value < mn:
errors.append(f"{path} must be >= {mn}, got {value}")
if mx is not None and value > mx:
errors.append(f"{path} must be <= {mx}, got {value}")
# ---------------------------------------------------------------------------
# Patch application
# ---------------------------------------------------------------------------
_PATH_TOKEN_RE = re.compile(r"\.?([^\.\[\]]+)|\[(\d+)\]")
def _split_path(path: str) -> list[tuple[str, int | None]]:
"""Split a dotted path into tokens. Supports `arr[0].field` syntax."""
tokens: list[tuple[str, int | None]] = []
for m in _PATH_TOKEN_RE.finditer(path):
if m.group(1) is not None and m.group(1) != "":
tokens.append((m.group(1), None))
elif m.group(2) is not None:
tokens.append(("", int(m.group(2))))
return tokens
def _navigate(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, Any, str]:
"""Walk into state along tokens. Returns (ok, value, error)."""
cur = state
for i, (key, idx) in enumerate(tokens):
if idx is not None:
if not isinstance(cur, list):
return False, None, f"cannot index into non-list at {'.'.join(t[0] for t in tokens[:i])}"
if idx >= len(cur):
return False, None, f"index {idx} out of range"
cur = cur[idx]
else:
if not isinstance(cur, dict):
return False, None, f"cannot key into non-object at {'.'.join(t[0] for t in tokens[:i])}"
if key not in cur:
return False, None, f"key {key!r} not found"
cur = cur[key]
return True, cur, ""
def _set_path(state: Any, tokens: list[tuple[str, int | None]], value: Any) -> tuple[bool, str]:
"""Set value at path, creating intermediate dicts as needed."""
if not tokens:
return False, "empty path"
cur = state
for i, (key, idx) in enumerate(tokens[:-1]):
nxt_key, nxt_idx = tokens[i + 1]
if idx is not None:
# current is list — descend by index
if not isinstance(cur, list):
return False, "cannot index non-list"
while len(cur) <= idx:
cur.append({})
cur = cur[idx]
else:
if not isinstance(cur, dict):
return False, "cannot key non-object"
if key not in cur:
cur[key] = [] if nxt_idx is not None else {}
cur = cur[key]
# last token
last_key, last_idx = tokens[-1]
if last_idx is not None:
if not isinstance(cur, list):
return False, "cannot index non-list"
while len(cur) <= last_idx:
cur.append(None)
cur[last_idx] = value
else:
if not isinstance(cur, dict):
return False, "cannot key non-object"
cur[last_key] = value
return True, ""
def _remove_path(state: Any, tokens: list[tuple[str, int | None]]) -> tuple[bool, str]:
"""Remove the value at path."""
if not tokens:
return False, "empty path"
parent_tokens = tokens[:-1]
ok, parent, err = _navigate(state, parent_tokens)
if not ok:
return False, err
last_key, last_idx = tokens[-1]
if last_idx is not None:
if not isinstance(parent, list):
return False, "cannot index non-list"
if last_idx >= len(parent):
return False, "index out of range"
parent.pop(last_idx)
else:
if not isinstance(parent, dict):
return False, "cannot key non-object"
if last_key not in parent:
return False, f"key {last_key!r} not found"
del parent[last_key]
return True, ""
def apply_patch(state: dict[str, Any], patch: dict[str, Any]) -> tuple[dict[str, Any], list[str]]:
"""Apply a patch to `state`. Returns (new_state, errors).
Patch format: `{field_path: new_value | {op: ..., by: N | value: V}}`.
Supported ops: `set` (default), `inc`, `dec`, `append`, `remove`.
The state is mutated in place — pass a deepcopy if you need to preserve the original.
"""
import copy
state = copy.deepcopy(state)
errors: list[str] = []
for path, op_spec in patch.items():
tokens = _split_path(path)
if not tokens:
errors.append(f"invalid path: {path!r}")
continue
# Determine if this is an op-dict or a direct value
if isinstance(op_spec, dict) and "op" in op_spec and op_spec["op"] in _PATCH_OPS:
op = op_spec["op"]
if op == "set":
ok, err = _set_path(state, tokens, op_spec.get("value"))
if not ok:
errors.append(f"{path}: {err}")
elif op in ("inc", "dec"):
by = op_spec.get("by", 1)
if op == "dec":
by = -by
ok, cur, err = _navigate(state, tokens)
if not ok:
# create with the delta value
ok2, err2 = _set_path(state, tokens, by)
if not ok2:
errors.append(f"{path}: {err2}")
else:
if isinstance(cur, bool) or not isinstance(cur, (int, float)):
errors.append(f"{path}: cannot {op} non-number")
else:
ok2, err2 = _set_path(state, tokens, cur + by)
if not ok2:
errors.append(f"{path}: {err2}")
elif op == "append":
value = op_spec.get("value")
ok, cur, err = _navigate(state, tokens)
if not ok:
# create empty list, then append
ok2, err2 = _set_path(state, tokens, [value])
if not ok2:
errors.append(f"{path}: {err2}")
else:
if not isinstance(cur, list):
errors.append(f"{path}: cannot append to non-list")
else:
cur.append(value)
elif op == "remove":
ok, err = _remove_path(state, tokens)
if not ok:
errors.append(f"{path}: {err}")
else:
# Direct value assignment
ok, err = _set_path(state, tokens, op_spec)
if not ok:
errors.append(f"{path}: {err}")
return state, errors
def validate_world(world_dict: dict[str, Any]) -> tuple[bool, list[str]]:
"""Top-level validation of a World dict.
Checks: presence of required keys, types of basic fields, and that
`environment` validates against `environment_schema`.
"""
errors: list[str] = []
required_top = ["name", "language", "schemas", "environment_schema", "environment"]
for k in required_top:
if k not in world_dict:
errors.append(f"Missing required world field: {k}")
# environment must validate against environment_schema
env_schema = world_dict.get("environment_schema", [])
env = world_dict.get("environment", {})
if env_schema and env:
ok, env_errors = validate_state(env, env_schema)
if not ok:
errors.extend(env_errors)
# plot_rails structure
pr = world_dict.get("plot_rails") or {}
for k in ("hooks", "current_goals", "completed_goals"):
if k not in pr:
errors.append(f"plot_rails missing key: {k}")
elif not isinstance(pr[k], list):
errors.append(f"plot_rails.{k} must be list")
# current_time format
ct = world_dict.get("current_time")
if ct:
from app.core.time_utils import GameTime
try:
GameTime.parse(ct)
except ValueError as e:
errors.append(str(e))
return (len(errors) == 0, errors)

148
app/core/time_utils.py Normal file
View File

@@ -0,0 +1,148 @@
"""Helpers for parsing/advancing in-game time strings.
Time format: `day_D_hour_H[_min_M]` (optionally with `year_Y_` prefix).
Examples:
- `day_1_hour_8` -> (1, 8, 0)
- `day_3_hour_14_min_30` -> (3, 14, 30)
- `year_2_day_5_hour_12` -> (2, 5, 12, 0)
Delta format: `[year_Y][days_D][hours_H][min_M]`
Examples: `hours_2_min_30`, `days_1`, `min_15`, `days_3_hours_2`
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Iterable
_TIME_RE = re.compile(
r"^(?:year_(\d+)_)?day_(\d+)_hour_(\d+)(?:_min_(\d+))?$"
)
_DELTA_RE = re.compile(
r"^(?:(?:year_(\d+)_)?(?:days_(\d+)_)?(?:hours_(\d+)_)?(?:min_(\d+))?)$"
)
@dataclass(frozen=True)
class GameTime:
year: int = 1
day: int = 1
hour: int = 0
minute: int = 0
def __post_init__(self):
if self.year < 1 or self.day < 1 or self.hour < 0 or self.minute < 0:
raise ValueError(f"Invalid GameTime: {self}")
if self.hour > 23:
raise ValueError(f"Hour out of range: {self.hour}")
if self.minute > 59:
raise ValueError(f"Minute out of range: {self.minute}")
@classmethod
def parse(cls, s: str) -> "GameTime":
m = _TIME_RE.match(s.strip())
if not m:
raise ValueError(f"Invalid time string: {s!r}")
year = int(m.group(1)) if m.group(1) else 1
day = int(m.group(2))
hour = int(m.group(3))
minute = int(m.group(4)) if m.group(4) else 0
return cls(year=year, day=day, hour=hour, minute=minute)
def to_string(self) -> str:
parts = []
if self.year != 1:
parts.append(f"year_{self.year}")
parts.append(f"day_{self.day}")
parts.append(f"hour_{self.hour}")
if self.minute:
parts.append(f"min_{self.minute}")
return "_".join(parts)
def total_minutes(self, hours_in_day: int = 24) -> int:
"""Total minutes since the start of year 1, day 1, hour 0."""
return (
(self.year - 1) * 365 * hours_in_day * 60
+ (self.day - 1) * hours_in_day * 60
+ self.hour * 60
+ self.minute
)
@classmethod
def from_total_minutes(cls, total: int, hours_in_day: int = 24) -> "GameTime":
year_len = 365 * hours_in_day * 60
day_len = hours_in_day * 60
year = total // year_len + 1
rem = total % year_len
day = rem // day_len + 1
rem = rem % day_len
hour = rem // 60
minute = rem % 60
return cls(year=year, day=day, hour=hour, minute=minute)
def parse_delta(delta: str) -> tuple[int, int, int, int]:
"""Parse a delta string, return (years, days, hours, minutes).
Accepted formats:
- `hours_2`, `min_30`, `days_1`, `year_2`
- `hours_2_min_30`, `days_3_hours_4`, `year_1_days_5_hours_2_min_15`
- `hours_2min_30` (no separator between components — also accepted)
"""
s = delta.strip()
if not s:
raise ValueError("Empty delta string")
parts: dict[str, int] = {"year": 0, "days": 0, "hours": 0, "min": 0}
# Use finditer to walk the string and ensure full coverage
pos = 0
matches = list(re.finditer(r"(year|days|hours|min)_(\d+)", s))
if not matches:
raise ValueError(f"Invalid delta string: {delta!r}")
for m in matches:
# Between matches, only underscores are allowed
gap = s[pos:m.start()]
if any(c != "_" for c in gap):
raise ValueError(f"Invalid delta string: {delta!r}")
parts[m.group(1)] += int(m.group(2))
pos = m.end()
# Trailing chars must also be underscores only
trailing = s[pos:]
if any(c != "_" for c in trailing):
raise ValueError(f"Invalid delta string: {delta!r}")
return (parts["year"], parts["days"], parts["hours"], parts["min"])
def advance_time(current: str, delta: str, time_schema: dict | None = None) -> str:
"""Advance `current` time string by `delta`. Returns new time string."""
schema = time_schema or {"hours_in_day": 24}
hours_in_day = int(schema.get("hours_in_day", 24))
gt = GameTime.parse(current)
y, d, h, mn = parse_delta(delta)
total = gt.total_minutes(hours_in_day) + (
y * 365 * hours_in_day * 60 + d * hours_in_day * 60 + h * 60 + mn
)
new_gt = GameTime.from_total_minutes(total, hours_in_day)
return new_gt.to_string()
def time_le(a: str, b: str) -> bool:
"""Return True if time `a` <= time `b`."""
ga, gb = GameTime.parse(a), GameTime.parse(b)
return ga.total_minutes() <= gb.total_minutes()
def summarize_schemas(schemas: Iterable[dict]) -> str:
"""Render a compact human-readable summary of entity schemas for LLM prompts."""
lines: list[str] = []
for s in schemas:
type_name = s.get("type", "?")
verbose = s.get("verbose", type_name)
props = s.get("properties", [])
prop_str = ", ".join(
f"{p.get('name')}:{p.get('type')}" + ("*" if p.get("required") else "")
for p in props
)
lines.append(f"- {verbose} ({type_name}): {prop_str}")
return "\n".join(lines) if lines else "(no schemas)"

70
app/db.py Normal file
View File

@@ -0,0 +1,70 @@
"""Async SQLAlchemy database session setup."""
from __future__ import annotations
from collections.abc import AsyncGenerator
from sqlalchemy.ext.asyncio import (
AsyncSession,
async_sessionmaker,
create_async_engine,
)
from sqlalchemy.orm import DeclarativeBase
from app.config import get_settings
class Base(DeclarativeBase):
"""Declarative base for all ORM models."""
_engine = None
_sessionmaker = None
def get_engine():
"""Lazy-create the global async engine."""
global _engine
if _engine is None:
cfg = get_settings()
_engine = create_async_engine(
cfg.database_url,
echo=cfg.db_echo,
pool_size=cfg.db_pool_size,
max_overflow=cfg.db_max_overflow,
future=True,
)
return _engine
def get_sessionmaker() -> async_sessionmaker[AsyncSession]:
"""Lazy-create the global session factory."""
global _sessionmaker
if _sessionmaker is None:
_sessionmaker = async_sessionmaker(
get_engine(),
class_=AsyncSession,
expire_on_commit=False,
autoflush=False,
)
return _sessionmaker
async def get_db() -> AsyncGenerator[AsyncSession, None]:
"""FastAPI dependency: yields an async session and rolls back on error."""
sm = get_sessionmaker()
async with sm() as session:
try:
yield session
except Exception:
await session.rollback()
raise
async def dispose_engine() -> None:
"""Dispose engine on application shutdown."""
global _engine, _sessionmaker
if _engine is not None:
await _engine.dispose()
_engine = None
_sessionmaker = None

1
app/engine/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Engine layer — game logic (orchestrator, world builder/editor, tools, context)."""

180
app/engine/context.py Normal file
View File

@@ -0,0 +1,180 @@
"""Context manager — builds the LLM message list per stage.
Implements the compression strategy from §10.3 of the TDD:
- If history > threshold, prepend the latest summary as a system message.
- Truncate to last N guaranteed messages.
- Optionally include RAG results.
"""
from __future__ import annotations
import json
from typing import Any
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.core.settings_service import get_setting
from app.core.time_utils import summarize_schemas
from app.models import StoryEntry, Step, World
from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
def _scene_text_truncate(text: str, max_tokens: int) -> str:
"""Crude truncation: ~4 chars per token."""
max_chars = max_tokens * 4
if len(text) <= max_chars:
return text
return text[:max_chars] + ""
async def build_orchestrator_phase1_context(
*,
db: AsyncSession,
world: World,
player_action: str,
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build messages list for orchestrator Phase 1."""
guaranteed = int(settings.get("context.guaranteed_messages", 10))
threshold = int(settings.get("context.compression_threshold_messages", 20))
scene_trunc = int(settings.get("context.scene_text_truncate_tokens", 500))
# Fetch recent steps (most recent first)
recent_steps = list(
reversed(
(
await db.execute(
select(Step)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(max(threshold, guaranteed) + 1)
)
).scalars().all()
)
)
# Pull latest summary if available
summary_text: str | None = None
if len(recent_steps) > threshold:
latest_summary = (
await db.execute(
select(StoryEntry)
.where(
StoryEntry.world_id == world.id,
StoryEntry.entry_type == "event",
StoryEntry.metadata_["type"].as_string() == "summary",
)
.order_by(StoryEntry.created_at.desc())
.limit(1)
)
).scalar_one_or_none()
if latest_summary:
summary_text = latest_summary.content
# Build the message list
sys_prompt = get_prompt("orchestrator_phase1", "en").format(
world_name=world.name,
rules="\n".join(f"- {r}" for r in (world.rules or [])),
schemas_summary=summarize_schemas(world.schemas or []),
environment_json=json.dumps(world.environment or {}, ensure_ascii=False, indent=2),
plot_rails_json=json.dumps(world.plot_rails or {}, ensure_ascii=False, indent=2),
current_time=world.current_time,
recent_history=_format_recent_history(
recent_steps[-guaranteed:], scene_trunc
),
max_substeps=settings.get("game.max_substeps_per_iteration", 8),
language=world.language,
player_action=player_action,
)
messages: list[dict[str, Any]] = [{"role": "system", "content": sys_prompt}]
if summary_text:
messages.append({
"role": "system",
"content": f"Summary of earlier events:\n{summary_text}",
})
# Recent steps as user/assistant pairs
for s in recent_steps[-guaranteed:]:
messages.append({"role": "user", "content": s.player_action})
if s.scene_text:
messages.append({"role": "assistant", "content": s.scene_text})
# Current action
messages.append({"role": "user", "content": player_action})
return messages
def _format_recent_history(steps: list[Step], scene_trunc: int) -> str:
if not steps:
return "(no recent history)"
lines: list[str] = []
for s in steps[-5:]: # only show last 5 in the prompt
text = _scene_text_truncate(s.scene_text or "(no scene)", scene_trunc)
lines.append(f"[step {s.sequence_number}] {s.player_action}\n{text}")
return "\n".join(lines)
async def build_orchestrator_phase2_context(
*,
db: AsyncSession,
world: World,
player_action: str,
plan: str,
summary: list[dict[str, Any]],
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build messages list for orchestrator Phase 2 (writer)."""
sys_prompt = get_prompt("orchestrator_phase2", "en").format(
world_name=world.name,
world_description=world.description or "",
language=world.language,
current_time=world.current_time,
player_action=player_action,
plan=plan,
summary_json=json.dumps(summary, ensure_ascii=False, indent=2),
environment_json=json.dumps(world.environment or {}, ensure_ascii=False, indent=2),
)
return [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": "Write the scene and call submit_step."},
]
async def build_orchestrator_phase3_suggest_context(
*,
db: AsyncSession,
world: World,
scene_text: str,
settings: dict[str, Any],
) -> list[dict[str, Any]]:
sys_prompt = get_prompt("orchestrator_phase3_suggest", "en").format(
language=world.language,
scene_text=scene_text[:2000],
current_goals=", ".join((world.plot_rails or {}).get("current_goals", []) or ["(none)"]),
)
return [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": "Suggest 1-3 next actions."},
]
async def build_summary_context(
*,
db: AsyncSession,
world: World,
old_steps: list[Step],
settings: dict[str, Any],
) -> list[dict[str, Any]]:
"""Build messages list for the summary LLM call."""
messages_json = json.dumps(
[{"action": s.player_action, "scene": s.scene_text} for s in old_steps],
ensure_ascii=False,
indent=2,
)
sys_prompt = get_prompt("summary", "en").format(messages_json=messages_json)
return [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": "Summarize."},
]

314
app/engine/game_master.py Normal file
View File

@@ -0,0 +1,314 @@
"""Game Master (orchestrator) — three-phase iteration engine.
Phase 1: Planner + Executor (tool-calling loop until submit_plan)
Phase 2: Writer (single LLM call with submit_step tool)
Phase 3: Persist + Deferred triggers + Summary + Suggest actions
"""
from __future__ import annotations
import json
import uuid
from datetime import datetime, timezone
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.rag import rag_add
from app.core.settings_service import get_all_settings
from app.core.time_utils import advance_time, summarize_schemas
from app.engine.context import (
build_orchestrator_phase1_context,
build_orchestrator_phase2_context,
build_orchestrator_phase3_suggest_context,
build_summary_context,
)
from app.engine.sse import SseEmitter
from app.engine.tools.base import ToolContext, get_registry
from app.engine.world_builder import _run_tool_loop
from app.models import DeferredTrigger, Step, StoryEntry, World
_logger = get_logger(__name__)
async def run_iteration(
*,
db: AsyncSession,
world: World,
step: Step,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
) -> None:
"""Run the full three-phase orchestrator iteration for a single step."""
settings = await get_all_settings(db)
try:
# ============ Phase 1 ============
await sse.emit("phase_start", {"phase": 1, "name": "planner_executor"})
messages = await build_orchestrator_phase1_context(
db=db, world=world, player_action=step.player_action, settings=settings,
)
phase1_result = await _run_tool_loop(
db=db, world=world, llm=llm, sse=sse,
stage="orchestrator_phase1",
system_prompt=messages[0]["content"],
terminal_tool="submit_plan",
max_substeps=int(settings.get("game.max_substeps_per_iteration", 8)),
settings=settings,
)
await sse.emit("phase_end", {"phase": 1, "duration_ms": 0})
if not phase1_result or not phase1_result.get("ok"):
# Force-completion: synthesize a minimal plan
phase1_result = {
"ok": True,
"data": {
"plan": "The action was processed but no explicit plan was submitted.",
"summary": [],
"offscreen_events": [],
},
}
plan = phase1_result["data"].get("plan", "")
summary = phase1_result["data"].get("summary", [])
offscreen_events = phase1_result["data"].get("offscreen_events", [])
# Persist tool_calls_summary on the step
step.tool_calls_summary = summary
await db.commit()
# ============ Phase 2: Writer ============
await sse.emit("phase_start", {"phase": 2, "name": "writer"})
messages = await build_orchestrator_phase2_context(
db=db, world=world, player_action=step.player_action,
plan=plan, summary=summary, settings=settings,
)
registry = get_registry()
ctx = ToolContext(db=db, world=world, step_id=step.id, stage="orchestrator_phase2",
sse_emitter=sse.emit)
tools = registry.to_openai_format("orchestrator_phase2")
phase2_msg: dict[str, Any] = {}
for retry in range(3):
resp = await llm.complete(
stage="orchestrator_phase2",
messages=messages,
tools=tools,
temperature=float(settings.get("llm.temperature_writer", 0.85)),
max_tokens=int(settings.get("llm.max_tokens", 2048)),
world_id=world.id, step_id=step.id, session=db,
)
phase2_msg = resp.get("message", {})
tcs = phase2_msg.get("tool_calls") or []
if tcs:
# Execute submit_step
for tc in tcs:
fn = tc.get("function", {})
if fn.get("name") == "submit_step":
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
result = await registry.execute("submit_step", args, ctx)
if result.ok:
scene_text = result.data.get("scene_text", "")
delta_time = result.data.get("delta_time", "hours_1")
step.scene_text = scene_text
step.scene_delta_time = delta_time
await sse.emit("scene_complete", {
"text": scene_text, "delta_time": delta_time,
})
break
if step.scene_text:
break
# Retry
messages.append(phase2_msg)
messages.append({
"role": "user",
"content": "You MUST call submit_step with scene_text and delta_time.",
})
else:
await sse.error("writer_no_submit", "Writer failed to call submit_step after 3 retries")
step.status = "failed"
await db.commit()
return
await sse.emit("phase_end", {"phase": 2, "duration_ms": 0})
# ============ Phase 3 ============
await sse.emit("phase_start", {"phase": 3, "name": "persist_triggers_summary_suggest"})
# 3.0 Persist
step.status = "completed"
world.last_played_at = datetime.now(timezone.utc)
world.current_time = advance_time(
world.current_time, step.scene_delta_time or "hours_1", world.time_schema
)
await db.commit()
# 3.1 Deferred triggers
if settings.get("game.deferred_triggers_enabled", True):
await _process_deferred_triggers(
db=db, world=world, step=step, llm=llm, sse=sse, settings=settings,
)
# 3.2 Summary (if history is too long)
await _maybe_generate_summary(
db=db, world=world, step=step, llm=llm, sse=sse, settings=settings,
)
# 3.3 Suggest actions
suggest_msgs = await build_orchestrator_phase3_suggest_context(
db=db, world=world, scene_text=step.scene_text or "", settings=settings,
)
suggest_tools = registry.to_openai_format("orchestrator_phase3_suggest")
for retry in range(2):
resp = await llm.complete(
stage="orchestrator_phase3_suggest",
messages=suggest_msgs,
tools=suggest_tools,
temperature=0.8,
max_tokens=512,
world_id=world.id, step_id=step.id, session=db,
)
msg = resp.get("message", {})
tcs = msg.get("tool_calls") or []
for tc in tcs:
fn = tc.get("function", {})
if fn.get("name") == "suggest_actions":
try:
args = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
args = {}
result = await registry.execute("suggest_actions", args, ctx)
if result.ok:
step.suggested_actions = result.data.get("actions", [])
await sse.emit("suggested_actions", {"actions": step.suggested_actions})
break
if step.suggested_actions:
break
suggest_msgs.append(msg)
suggest_msgs.append({"role": "user", "content": "Call suggest_actions with 1-3 actions."})
await db.commit()
await sse.emit("iteration_complete", {
"step_id": str(step.id), "sequence_number": step.sequence_number,
})
await sse.done({"step_id": str(step.id), "status": "completed"})
except Exception as e: # noqa: BLE001
_logger.exception("orchestrator_failed", step_id=str(step.id), error=str(e))
step.status = "failed"
await db.commit()
await sse.error("internal_error", str(e))
async def _process_deferred_triggers(
*,
db: AsyncSession,
world: World,
step: Step,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
settings: dict[str, Any],
) -> None:
"""Fire all deferred triggers whose fire_at <= current_time."""
from app.core.time_utils import time_le
triggers = (
await db.execute(
select(DeferredTrigger).where(
DeferredTrigger.world_id == world.id,
DeferredTrigger.is_fired.is_(False),
)
)
).scalars().all()
fired = 0
for trig in triggers:
try:
if not time_le(trig.fire_at, world.current_time):
continue
except Exception: # noqa: BLE001
continue
# Simple firing: append a note to scene_text
summary = f"\n\n[Offscreen event: {trig.event_type} — payload: {json.dumps(trig.payload, ensure_ascii=False)}]"
if step.scene_text:
step.scene_text += summary
else:
step.scene_text = summary
trig.is_fired = True
trig.fired_at = datetime.now(timezone.utc)
await db.flush()
await sse.emit("trigger_fired", {
"trigger_id": str(trig.id), "event_type": trig.event_type,
"summary": summary.strip(),
})
fired += 1
# Persist the trigger event as a story entry
await rag_add(
db=db, world_id=world.id,
content=f"Deferred trigger fired: {trig.event_type} at {trig.fire_at}",
entry_type="event",
metadata={"trigger_id": str(trig.id), "step_id": str(step.id)},
step_id=step.id,
)
if fired:
await db.commit()
async def _maybe_generate_summary(
*,
db: AsyncSession,
world: World,
step: Step,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
settings: dict[str, Any],
) -> None:
"""Generate a summary if recent step count exceeds the threshold."""
threshold = int(settings.get("context.compression_threshold_messages", 20))
guaranteed = int(settings.get("context.guaranteed_messages", 10))
recent_steps = list(
reversed(
(
await db.execute(
select(Step)
.where(Step.world_id == world.id, Step.deleted_at.is_(None))
.order_by(Step.sequence_number.desc())
.limit(threshold + 1)
)
).scalars().all()
)
)
if len(recent_steps) <= threshold:
return
old_steps = recent_steps[:-guaranteed]
if not old_steps:
return
messages = await build_summary_context(
db=db, world=world, old_steps=old_steps, settings=settings,
)
resp = await llm.complete(
stage="orchestrator_phase3_summary",
messages=messages,
temperature=0.3,
max_tokens=1024,
world_id=world.id, step_id=step.id, session=db,
)
summary_text = resp.get("message", {}).get("content", "")
if not summary_text:
return
# Store as a story entry
se = StoryEntry(
world_id=world.id,
content=summary_text,
entry_type="event",
metadata_={
"type": "summary",
"step_range": [old_steps[0].sequence_number, old_steps[-1].sequence_number],
},
embedding_status="pending",
)
db.add(se)
await db.commit()
await sse.emit("summary_generated", {
"summary_id": str(se.id),
"message_range": [old_steps[0].sequence_number, old_steps[-1].sequence_number],
})

84
app/engine/sse.py Normal file
View File

@@ -0,0 +1,84 @@
"""SSE event emitter — wraps sse-starlette to emit typed events."""
from __future__ import annotations
import asyncio
import json
import uuid
from collections.abc import AsyncIterator
from typing import Any
from app.core.logging import get_logger
_logger = get_logger(__name__)
class SseEmitter:
"""Async queue-based SSE emitter.
Usage:
emitter = SseEmitter()
async with emitter.stream() as stream:
async for event in stream:
yield event
In a producer task:
await emitter.emit("tool_call", {...})
await emitter.done({"result": "ok"})
"""
def __init__(self) -> None:
self._queue: asyncio.Queue[tuple[str, str, str] | None] = asyncio.Queue()
# (event_type, data_json, event_id)
self._event_counter = 0
self._closed = False
async def emit(self, event_type: str, data: Any) -> None:
if self._closed:
return
self._event_counter += 1
event_id = f"evt_{self._event_counter}"
try:
data_str = json.dumps(data, ensure_ascii=False, default=str)
except (TypeError, ValueError):
data_str = json.dumps({"error": "serialization_failed"})
await self._queue.put((event_type, data_str, event_id))
async def ping(self) -> None:
await self.emit("ping", {"ts": _now_iso()})
async def done(self, result: Any = None) -> None:
await self.emit("done", result if result is not None else {})
await self._queue.put(None) # sentinel
self._closed = True
async def error(self, code: str, message: str, details: Any = None) -> None:
payload: dict[str, Any] = {"code": code, "message": message}
if details is not None:
payload["details"] = details
await self.emit("error", payload)
await self._queue.put(None)
self._closed = True
async def stream(self) -> AsyncIterator[dict[str, str]]:
"""Yield SSE-formatted dicts until the emitter is closed."""
try:
while True:
item = await self._queue.get()
if item is None:
break
event_type, data_str, event_id = item
yield {
"event": event_type,
"data": data_str,
"id": event_id,
}
except asyncio.CancelledError:
_logger.info("sse_stream_cancelled")
raise
def _now_iso() -> str:
from datetime import datetime, timezone
return datetime.now(timezone.utc).isoformat()

View File

@@ -0,0 +1 @@
"""Tools package — game tools, interaction tools, schema tools."""

231
app/engine/tools/base.py Normal file
View File

@@ -0,0 +1,231 @@
"""Base types for tool system: Tool, ToolContext, ToolResult, ToolRegistry."""
from __future__ import annotations
import abc
import uuid
from collections.abc import Awaitable, Callable
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.logging import get_logger
from app.core.state_validator import apply_patch
from app.models import Entity, StepToolCall, World
_logger = get_logger(__name__)
# --------------------------------------------------------------------------- #
# Context & Result
# --------------------------------------------------------------------------- #
@dataclass
class ToolContext:
"""Per-iteration context passed to every tool call."""
db: AsyncSession
world: World
user_id: uuid.UUID | None = None
step_id: uuid.UUID | None = None
stage: str = ""
sse_emitter: Any = None # callable: async (event, data) -> None
pending_state_changes: dict[str, Any] = field(default_factory=dict)
@dataclass
class ToolResult:
ok: bool
data: dict[str, Any] = field(default_factory=dict)
message: str = ""
error_code: str = ""
error_message: str = ""
def to_dict(self) -> dict[str, Any]:
if self.ok:
return {"ok": True, "data": self.data, "message": self.message}
return {
"ok": False,
"error": {"code": self.error_code, "message": self.error_message},
}
# --------------------------------------------------------------------------- #
# Tool base class
# --------------------------------------------------------------------------- #
class Tool(abc.ABC):
"""Abstract base for all tools."""
name: str = ""
category: str = "game" # game | interaction | schema
stages: set[str] = set() # which stages can use this tool
description: str = ""
parameters_schema: dict[str, Any] = {}
@abc.abstractmethod
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
"""Run the tool. Must be idempotent within a single transaction."""
def to_openai_format(self) -> dict[str, Any]:
"""Serialize to OpenAI tools format."""
return {
"type": "function",
"function": {
"name": self.name,
"description": self.description,
"parameters": self.parameters_schema,
},
}
# --------------------------------------------------------------------------- #
# Registry
# --------------------------------------------------------------------------- #
class ToolRegistry:
"""Holds all registered tools and dispatches calls."""
def __init__(self) -> None:
self._tools: dict[str, Tool] = {}
def register(self, tool: Tool) -> None:
if not tool.name:
raise ValueError("Tool name is required")
if tool.name in self._tools:
raise ValueError(f"Tool {tool.name} already registered")
self._tools[tool.name] = tool
def get(self, name: str) -> Tool | None:
return self._tools.get(name)
def list_for_stage(self, stage: str) -> list[Tool]:
"""Return all tools available at the given stage."""
return [t for t in self._tools.values() if stage in t.stages or "*" in t.stages]
def to_openai_format(self, stage: str) -> list[dict[str, Any]]:
return [t.to_openai_format() for t in self.list_for_stage(stage)]
async def execute(
self,
name: str,
arguments: dict[str, Any],
ctx: ToolContext,
) -> ToolResult:
"""Execute a tool by name. Logs to step_tool_calls and emits SSE."""
from sqlalchemy import select as sa_select
tool = self.get(name)
executed_at = datetime.now(timezone.utc)
if tool is None:
result = ToolResult(
ok=False,
error_code="unknown_tool",
error_message=f"Tool {name!r} is not registered",
)
else:
try:
result = await tool.execute(arguments, ctx)
except Exception as e: # noqa: BLE001
_logger.exception("tool_execution_failed", tool=name, error=str(e))
result = ToolResult(
ok=False,
error_code="tool_exception",
error_message=str(e),
)
# Log to step_tool_calls (if we have a step_id)
if ctx.step_id is not None:
try:
ctx.db.add(
StepToolCall(
step_id=ctx.step_id,
tool_name=name,
arguments=arguments,
result=result.to_dict(),
is_success=result.ok,
executed_at=executed_at,
)
)
await ctx.db.flush()
except Exception as e: # noqa: BLE001
_logger.error("tool_log_failed", tool=name, error=str(e))
# Emit SSE
if ctx.sse_emitter is not None:
try:
await ctx.sse_emitter(
"tool_call",
{
"tool": name,
"arguments": arguments,
"result": result.to_dict(),
"is_success": result.ok,
},
)
except Exception as e: # noqa: BLE001
_logger.warning("sse_tool_call_failed", tool=name, error=str(e))
return result
# --------------------------------------------------------------------------- #
# Helpers used by entity_* tools
# --------------------------------------------------------------------------- #
async def get_entity_by_query(
db: AsyncSession, world_id: uuid.UUID, query: Any
) -> Entity | None:
"""Resolve entity by UUID string or by {entity_type, name}."""
from sqlalchemy import select as sa_select
if isinstance(query, str):
try:
eid = uuid.UUID(query)
except ValueError:
return None
return (
await db.execute(
sa_select(Entity).where(
Entity.id == eid, Entity.world_id == world_id
)
)
).scalar_one_or_none()
elif isinstance(query, dict):
et = query.get("entity_type")
nm = query.get("name")
if not et or not nm:
return None
return (
await db.execute(
sa_select(Entity).where(
Entity.world_id == world_id,
Entity.entity_type == et,
Entity.name == nm,
Entity.deleted_at.is_(None),
)
)
).scalar_one_or_none()
return None
def apply_env_patch(environment: dict, patch: dict) -> tuple[dict, list[str]]:
"""Wrapper around state_validator.apply_patch for environment dicts."""
return apply_patch(environment, patch)
# Singleton registry (instantiated in `app.engine.tools.__init__`)
_registry: ToolRegistry | None = None
def get_registry() -> ToolRegistry:
global _registry
if _registry is None:
from app.engine.tools.register_all import build_default_registry
_registry = build_default_registry()
return _registry
def reset_registry() -> None:
"""Reset the cached registry — used in tests."""
global _registry
_registry = None

993
app/engine/tools/game.py Normal file
View File

@@ -0,0 +1,993 @@
"""Game tools — entity CRUD, environment manipulation, RAG, triggers, calc, etc."""
from __future__ import annotations
import random
import re
import uuid
from typing import Any
from sqlalchemy import select
from app.core.logging import get_logger
from app.core.state_validator import apply_patch, validate_state
from app.engine.tools.base import Tool, ToolContext, ToolResult, get_entity_by_query
from app.models import Entity
_logger = get_logger(__name__)
# --------------------------------------------------------------------------- #
# entity_create
# --------------------------------------------------------------------------- #
class EntityCreateTool(Tool):
name = "entity_create"
category = "game"
stages = {"world_builder", "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."
)
parameters_schema = {
"type": "object",
"required": ["entity_type", "name", "data"],
"properties": {
"entity_type": {
"type": "string",
"description": "Type from world.schemas (character, item, location, ...)",
},
"name": {
"type": "string",
"description": "Entity name (unique within (world_id, entity_type))",
},
"data": {
"type": "object",
"description": "Full entity data per schema",
},
"add_to_environment": {
"type": "boolean",
"default": False,
"description": "Add to environment for fast LLM access",
},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
nm = arguments.get("name")
data = arguments.get("data") or {}
add_env = arguments.get("add_to_environment", False)
if not et or not nm:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="entity_type and name are required",
)
# Validate type exists in schemas
schemas = ctx.world.schemas or []
type_names = {s.get("type") for s in schemas}
if et not in type_names:
return ToolResult(
ok=False,
error_code="unknown_entity_type",
error_message=f"Entity type {et!r} not in world.schemas",
)
# Name uniqueness within (world, type)
existing = (
await ctx.db.execute(
select(Entity).where(
Entity.world_id == ctx.world.id,
Entity.entity_type == et,
Entity.name == nm,
Entity.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if existing is not None:
return ToolResult(
ok=False,
error_code="name_conflict",
error_message=f"Entity {et}/{nm!r} already exists",
)
entity = Entity(
world_id=ctx.world.id,
entity_type=et,
name=nm,
data=data,
is_in_environment=add_env,
embedding_status="pending",
)
ctx.db.add(entity)
await ctx.db.flush()
if add_env:
env = dict(ctx.world.environment or {})
env.setdefault("entities", []).append(
{"id": str(entity.id), "entity_type": et, "name": nm}
)
ctx.world.environment = env
return ToolResult(
ok=True,
data={"entity_id": str(entity.id)},
message=f"Created {et} {nm!r}",
)
# --------------------------------------------------------------------------- #
# entity_get
# --------------------------------------------------------------------------- #
class EntityGetTool(Tool):
name = "entity_get"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = "Get an entity by id or by {entity_type, name}."
parameters_schema = {
"type": "object",
"required": ["query"],
"properties": {
"query": {
"oneOf": [
{"type": "string", "description": "entity_id (UUID)"},
{
"type": "object",
"properties": {
"entity_type": {"type": "string"},
"name": {"type": "string"},
},
"required": ["entity_type", "name"],
},
]
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
q = arguments.get("query")
ent = await get_entity_by_query(ctx.db, ctx.world.id, q)
if ent is None or ent.deleted_at is not None:
return ToolResult(
ok=False,
error_code="not_found",
error_message="Entity not found",
)
return ToolResult(
ok=True,
data={
"id": str(ent.id),
"entity_type": ent.entity_type,
"name": ent.name,
"data": ent.data,
"is_in_environment": ent.is_in_environment,
},
message=f"Got {ent.entity_type} {ent.name!r}",
)
# --------------------------------------------------------------------------- #
# entity_list
# --------------------------------------------------------------------------- #
class EntityListTool(Tool):
name = "entity_list"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = "List entities in the world, optionally filtered."
parameters_schema = {
"type": "object",
"properties": {
"entity_type": {"type": "string"},
"in_environment_only": {"type": "boolean", "default": False},
"name_contains": {"type": "string"},
"limit": {"type": "integer", "default": 50, "max": 200},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
stmt = select(Entity).where(
Entity.world_id == ctx.world.id, Entity.deleted_at.is_(None)
)
et = arguments.get("entity_type")
if et:
stmt = stmt.where(Entity.entity_type == et)
if arguments.get("in_environment_only"):
stmt = stmt.where(Entity.is_in_environment.is_(True))
nc = arguments.get("name_contains")
if nc:
stmt = stmt.where(Entity.name.ilike(f"%{nc}%"))
limit = min(arguments.get("limit", 50), 200)
stmt = stmt.limit(limit)
rows = (await ctx.db.execute(stmt)).scalars().all()
return ToolResult(
ok=True,
data={
"items": [
{
"id": str(r.id),
"entity_type": r.entity_type,
"name": r.name,
"data": r.data,
"is_in_environment": r.is_in_environment,
}
for r in rows
],
"count": len(rows),
},
message=f"Listed {len(rows)} entities",
)
# --------------------------------------------------------------------------- #
# entity_update
# --------------------------------------------------------------------------- #
class EntityUpdateTool(Tool):
name = "entity_update"
category = "game"
stages = {"world_editor", "orchestrator_phase1", "subagent"}
description = "Update entity fields via JSON-patch."
parameters_schema = {
"type": "object",
"required": ["entity_id", "patch"],
"properties": {
"entity_id": {"type": "string"},
"patch": {
"type": "object",
"description": "JSON-patch: {field_path: new_value | {op, by}}",
},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
eid = arguments.get("entity_id")
patch = arguments.get("patch") or {}
try:
ent_uuid = uuid.UUID(eid)
except (ValueError, TypeError):
return ToolResult(
ok=False, error_code="validation_error", error_message="Invalid entity_id"
)
ent = (
await ctx.db.execute(
select(Entity).where(
Entity.id == ent_uuid,
Entity.world_id == ctx.world.id,
Entity.deleted_at.is_(None),
)
)
).scalar_one_or_none()
if ent is None:
return ToolResult(
ok=False, error_code="not_found", error_message="Entity not found"
)
new_data, errors = apply_patch(ent.data or {}, patch)
if errors:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="; ".join(errors),
)
ent.data = new_data
await ctx.db.flush()
return ToolResult(
ok=True,
data={"applied_paths": list(patch.keys())},
message=f"Updated {ent.entity_type} {ent.name!r}",
)
# --------------------------------------------------------------------------- #
# entity_delete
# --------------------------------------------------------------------------- #
class EntityDeleteTool(Tool):
name = "entity_delete"
category = "game"
stages = {"world_editor", "orchestrator_phase1", "subagent"}
description = "Soft-delete an entity."
parameters_schema = {
"type": "object",
"required": ["entity_id"],
"properties": {
"entity_id": {"type": "string"},
"reason": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
eid = arguments.get("entity_id")
try:
ent_uuid = uuid.UUID(eid)
except (ValueError, TypeError):
return ToolResult(
ok=False, error_code="validation_error", error_message="Invalid entity_id"
)
ent = (
await ctx.db.execute(
select(Entity).where(
Entity.id == ent_uuid, Entity.world_id == ctx.world.id
)
)
).scalar_one_or_none()
if ent is None:
return ToolResult(
ok=False, error_code="not_found", error_message="Entity not found"
)
from datetime import datetime, timezone
ent.deleted_at = datetime.now(timezone.utc)
await ctx.db.flush()
return ToolResult(
ok=True,
data={"entity_id": str(ent.id)},
message=f"Soft-deleted {ent.entity_type} {ent.name!r}",
)
# --------------------------------------------------------------------------- #
# env_update / env_get
# --------------------------------------------------------------------------- #
class EnvUpdateTool(Tool):
name = "env_update"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = (
"Apply a JSON-patch to environment. Patch is validated by state_validator."
)
parameters_schema = {
"type": "object",
"required": ["patch"],
"properties": {
"patch": {
"type": "object",
"description": "Map field_path -> new_value | {op, by/value}. Ops: set, inc, dec, append, remove.",
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
patch = arguments.get("patch") or {}
new_env, errors = apply_patch(dict(ctx.world.environment or {}), patch)
if errors:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="; ".join(errors),
)
# Validate against environment_schema
ok, verrors = validate_state(new_env, ctx.world.environment_schema or [])
if not ok:
return ToolResult(
ok=False,
error_code="schema_violation",
error_message="; ".join(verrors),
)
ctx.world.environment = new_env
await ctx.db.flush()
return ToolResult(
ok=True,
data={"applied_paths": list(patch.keys())},
message="Environment updated",
)
class EnvGetTool(Tool):
name = "env_get"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = "Get current environment value (or sub-path)."
parameters_schema = {
"type": "object",
"properties": {
"path": {
"type": "string",
"description": "e.g. 'player.stats' or 'plot_rails.current_goals'",
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
path = arguments.get("path")
env = ctx.world.environment or {}
if not path:
return ToolResult(ok=True, data=env, message="Full environment")
# Walk path
cur: Any = env
for part in path.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return ToolResult(
ok=False,
error_code="not_found",
error_message=f"Path {path!r} not found in environment",
)
return ToolResult(ok=True, data={"value": cur}, message=f"Value at {path!r}")
# --------------------------------------------------------------------------- #
# update_plot_rails
# --------------------------------------------------------------------------- #
class UpdatePlotRailsTool(Tool):
name = "update_plot_rails"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"intro_scene",
}
description = "Add/remove hooks and goals in plot_rails."
parameters_schema = {
"type": "object",
"required": ["operation"],
"properties": {
"operation": {
"type": "string",
"enum": ["add_hook", "remove_hook", "add_goal", "remove_goal", "complete_goal"],
},
"value": {"type": "string"},
"index": {"type": "integer"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
op = arguments.get("operation")
val = arguments.get("value")
idx = arguments.get("index")
pr = dict(ctx.world.plot_rails or {})
pr.setdefault("hooks", [])
pr.setdefault("current_goals", [])
pr.setdefault("completed_goals", [])
if op == "add_hook":
if not val:
return ToolResult(ok=False, error_code="validation_error",
error_message="value required for add_hook")
pr["hooks"] = list(pr["hooks"]) + [val]
elif op == "remove_hook":
if idx is None or idx >= len(pr["hooks"]):
return ToolResult(ok=False, error_code="validation_error",
error_message="invalid index")
pr["hooks"] = [h for i, h in enumerate(pr["hooks"]) if i != idx]
elif op == "add_goal":
if not val:
return ToolResult(ok=False, error_code="validation_error",
error_message="value required for add_goal")
pr["current_goals"] = list(pr["current_goals"]) + [val]
elif op == "remove_goal":
if idx is None or idx >= len(pr["current_goals"]):
return ToolResult(ok=False, error_code="validation_error",
error_message="invalid index")
pr["current_goals"] = [g for i, g in enumerate(pr["current_goals"]) if i != idx]
elif op == "complete_goal":
if idx is None or idx >= len(pr["current_goals"]):
return ToolResult(ok=False, error_code="validation_error",
error_message="invalid index")
goal = pr["current_goals"][idx]
pr["current_goals"] = [g for i, g in enumerate(pr["current_goals"]) if i != idx]
pr["completed_goals"] = list(pr["completed_goals"]) + [goal]
else:
return ToolResult(
ok=False,
error_code="validation_error",
error_message=f"Unknown operation {op!r}",
)
ctx.world.plot_rails = pr
await ctx.db.flush()
return ToolResult(ok=True, data=pr, message=f"plot_rails.{op} applied")
# --------------------------------------------------------------------------- #
# advance_time
# --------------------------------------------------------------------------- #
class AdvanceTimeTool(Tool):
name = "advance_time"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = "Advance world time by a delta."
parameters_schema = {
"type": "object",
"required": ["delta"],
"properties": {
"delta": {
"type": "string",
"description": "Format: [year_Y][days_D][hours_H][min_M]",
}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.core.time_utils import advance_time
delta = arguments.get("delta")
try:
new_time = advance_time(ctx.world.current_time, delta, ctx.world.time_schema)
except ValueError as e:
return ToolResult(ok=False, error_code="validation_error", error_message=str(e))
ctx.world.current_time = new_time
await ctx.db.flush()
return ToolResult(
ok=True,
data={"new_time": new_time},
message=f"Time advanced by {delta} to {new_time}",
)
# --------------------------------------------------------------------------- #
# schedule_trigger
# --------------------------------------------------------------------------- #
class ScheduleTriggerTool(Tool):
name = "schedule_trigger"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = "Schedule a deferred trigger to fire at a specific game time."
parameters_schema = {
"type": "object",
"required": ["fire_at", "event_type", "payload"],
"properties": {
"fire_at": {"type": "string", "description": "Format: [year_Y_]day_D_hour_H[_min_M]"},
"event_type": {
"type": "string",
"enum": ["spawn_enemy", "weather_change", "quest_update", "npc_action", "custom"],
},
"payload": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.models import DeferredTrigger
fa = arguments.get("fire_at")
et = arguments.get("event_type")
pl = arguments.get("payload") or {}
if not fa or not et:
return ToolResult(
ok=False, error_code="validation_error",
error_message="fire_at and event_type required",
)
trig = DeferredTrigger(
world_id=ctx.world.id, fire_at=fa, event_type=et, payload=pl
)
ctx.db.add(trig)
await ctx.db.flush()
return ToolResult(
ok=True,
data={"trigger_id": str(trig.id)},
message=f"Scheduled {et} at {fa}",
)
# --------------------------------------------------------------------------- #
# calc
# --------------------------------------------------------------------------- #
_DICE_RE = re.compile(r"(\d*)d(\d+)")
_SAFE_RE = re.compile(r"^[0-9+\-*/%().,\s\wd]+$")
class CalcTool(Tool):
name = "calc"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = (
"Evaluate a math expression with dice support. Allowed: + - * / %, "
"min(), max(), round(), and dice notation like 2d6+3."
)
parameters_schema = {
"type": "object",
"required": ["expression"],
"properties": {
"expression": {"type": "string", "example": "max(1, 2d6+3 - enemy.armor)"},
"variables": {
"type": "object",
"description": "Variable substitutions, e.g. {\"enemy.armor\": 5}",
},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
expr = arguments.get("expression", "")
variables = arguments.get("variables") or {}
# Substitute variables
trace_parts: list[str] = []
for var, val in variables.items():
expr = expr.replace(var, str(val))
trace_parts.append(f"{var}={val}")
# Dice rolls
rolls: list[int] = []
def _roll(match: re.Match) -> str:
count = int(match.group(1) or "1")
sides = int(match.group(2))
if sides < 1 or count < 1 or count > 100:
return "0"
results = [random.randint(1, sides) for _ in range(count)]
rolls.extend(results)
return str(sum(results))
expr_with_rolls = _DICE_RE.sub(_roll, expr)
if not _SAFE_RE.match(expr_with_rolls):
return ToolResult(
ok=False,
error_code="validation_error",
error_message="Expression contains disallowed characters",
)
# Replace min/max/round with safe builtins
try:
result = eval( # noqa: S307
expr_with_rolls,
{"__builtins__": {}},
{"min": min, "max": max, "round": round, "abs": abs},
)
if isinstance(result, float) and result.is_integer():
result = int(result)
except Exception as e:
return ToolResult(
ok=False, error_code="evaluation_error", error_message=str(e)
)
return ToolResult(
ok=True,
data={"result": result, "rolls": rolls, "trace": "; ".join(trace_parts)},
message=f"= {result}",
)
# --------------------------------------------------------------------------- #
# random_choice
# --------------------------------------------------------------------------- #
class RandomChoiceTool(Tool):
name = "random_choice"
category = "game"
stages = {"orchestrator_phase1", "subagent"}
description = "Pick an option deterministically (seeded by world+step)."
parameters_schema = {
"type": "object",
"required": ["options"],
"properties": {
"options": {"type": "array", "items": {}, "minItems": 2},
"weights": {"type": "array", "items": {"type": "number"}},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
opts = arguments.get("options") or []
weights = arguments.get("weights")
if len(opts) < 2:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="Need at least 2 options",
)
seed_str = f"{ctx.world.id}:{ctx.step_id or 'noid'}"
rng = random.Random(hash(seed_str))
if weights:
if len(weights) != len(opts):
return ToolResult(
ok=False, error_code="validation_error",
error_message="options and weights length mismatch",
)
pick = rng.choices(opts, weights=weights, k=1)[0]
else:
pick = rng.choice(opts)
return ToolResult(ok=True, data={"choice": pick}, message=f"Picked: {pick!r}")
# --------------------------------------------------------------------------- #
# rag_query / rag_add (deferred to app.core.rag)
# --------------------------------------------------------------------------- #
class RagQueryTool(Tool):
name = "rag_query"
category = "game"
stages = {
"world_builder",
"world_editor",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = (
"Semantic search over entities and story entries. Use when you need to recall "
"past details, NPC names, world facts. Do NOT rely on memory."
)
parameters_schema = {
"type": "object",
"required": ["query"],
"properties": {
"query": {"type": "string"},
"limit": {"type": "integer", "default": 5, "max": 20},
"filter_type": {
"type": "string",
"enum": ["all", "entities", "story_entries"],
"default": "all",
},
"min_score": {"type": "number", "default": 0.7},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.core.rag import rag_query
try:
results = await rag_query(
db=ctx.db,
world_id=ctx.world.id,
query=arguments.get("query", ""),
limit=arguments.get("limit", 5),
filter_type=arguments.get("filter_type", "all"),
min_score=arguments.get("min_score", 0.0),
)
except Exception as e: # noqa: BLE001
_logger.warning("rag_query_tool_failed", error=str(e))
return ToolResult(
ok=True,
data={"results": []},
message="RAG unavailable, returning empty results",
)
return ToolResult(
ok=True,
data={"results": results},
message=f"Found {len(results)} matches",
)
class RagAddTool(Tool):
name = "rag_add"
category = "game"
stages = {
"world_builder",
"orchestrator_phase1",
"subagent",
"intro_scene",
}
description = (
"Persist a fact/event as a story entry and index it for semantic search. "
"Use when the player learns a persistent fact (NPC secret, lore, quest outcome)."
)
parameters_schema = {
"type": "object",
"required": ["content", "entry_type"],
"properties": {
"content": {"type": "string"},
"entry_type": {
"type": "string",
"enum": ["fact", "event", "relationship", "secret"],
},
"metadata": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
from app.core.rag import rag_add
entry = await rag_add(
db=ctx.db,
world_id=ctx.world.id,
content=arguments.get("content", ""),
entry_type=arguments.get("entry_type", "fact"),
metadata=arguments.get("metadata"),
step_id=ctx.step_id,
)
return ToolResult(
ok=True,
data={"id": str(entry.id), "status": entry.embedding_status},
message=f"Added story entry ({entry.entry_type})",
)
# --------------------------------------------------------------------------- #
# submit_plan / submit_step / suggest_actions — terminal tools
# --------------------------------------------------------------------------- #
class SubmitPlanTool(Tool):
name = "submit_plan"
category = "game"
stages = {"world_builder", "orchestrator_phase1"}
description = "End Phase 1. Pass the plan + summary to Phase 2 writer."
parameters_schema = {
"type": "object",
"required": ["plan", "summary"],
"properties": {
"plan": {"type": "string"},
"summary": {"type": "array", "items": {"type": "object"}},
"offscreen_events": {"type": "array", "items": {"type": "string"}},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
# Signal handled by orchestrator loop — just echo back
return ToolResult(
ok=True,
data={
"plan": arguments.get("plan", ""),
"summary": arguments.get("summary", []),
"offscreen_events": arguments.get("offscreen_events", []),
},
message="Phase 1 complete",
)
class SubmitStepTool(Tool):
name = "submit_step"
category = "game"
stages = {"orchestrator_phase2", "intro_scene"}
description = "End Phase 2. Writer returns the final narrative + time delta."
parameters_schema = {
"type": "object",
"required": ["scene_text", "delta_time"],
"properties": {
"scene_text": {"type": "string", "minLength": 100, "maxLength": 4000},
"delta_time": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
text = arguments.get("scene_text", "")
if len(text) < 100:
return ToolResult(
ok=False,
error_code="validation_error",
error_message=f"scene_text must be >= 100 chars (got {len(text)})",
)
return ToolResult(
ok=True,
data={"scene_text": text, "delta_time": arguments.get("delta_time", "hours_1")},
message="Phase 2 complete",
)
class SuggestActionsTool(Tool):
name = "suggest_actions"
category = "game"
stages = {"orchestrator_phase3_suggest", "intro_scene"}
description = "Generate 1-3 next actions for the player."
parameters_schema = {
"type": "object",
"required": ["actions"],
"properties": {
"actions": {"type": "array", "items": {"type": "string"}, "minItems": 1, "maxItems": 3}
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
actions = arguments.get("actions") or []
if not actions or len(actions) > 3:
return ToolResult(
ok=False,
error_code="validation_error",
error_message="Need 1-3 actions",
)
return ToolResult(ok=True, data={"actions": actions}, message="Suggestions ready")
# --------------------------------------------------------------------------- #
# Interaction tools (used in world_builder / world_editor)
# --------------------------------------------------------------------------- #
class AskUserTool(Tool):
name = "ask_user"
category = "interaction"
stages = {"world_builder", "world_editor"}
description = "Ask the player a clarification question. Blocks until answer."
parameters_schema = {
"type": "object",
"required": ["question"],
"properties": {
"question": {"type": "string"},
"options": {"type": "array", "items": {"type": "string"}},
"allow_free_text": {"type": "boolean", "default": True},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
# The orchestrator/world_builder loop must intercept this tool before execution
# and emit a clarification SSE event; here we just echo back the question.
return ToolResult(
ok=True,
data={
"question": arguments.get("question"),
"options": arguments.get("options"),
"allow_free_text": arguments.get("allow_free_text", True),
"_blocking": True,
},
message="Awaiting user answer",
)
class ProposeChangesTool(Tool):
name = "propose_changes"
category = "interaction"
stages = {"world_editor"}
description = "Propose a diff to the player for accept/reject."
parameters_schema = {
"type": "object",
"required": ["diff"],
"properties": {
"diff": {
"type": "array",
"items": {
"type": "object",
"properties": {
"path": {"type": "string"},
"op": {"type": "string", "enum": ["add", "remove", "replace"]},
"old": {},
"new": {},
},
},
},
"comment": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
return ToolResult(
ok=True,
data={"diff": arguments.get("diff", []), "comment": arguments.get("comment", "")},
message="Proposed changes",
)
class CommentToUserTool(Tool):
name = "comment_to_user"
category = "interaction"
stages = {"world_builder", "world_editor"}
description = "Send a text comment to the user (no answer expected)."
parameters_schema = {
"type": "object",
"required": ["text"],
"properties": {"text": {"type": "string"}},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
return ToolResult(ok=True, data={"text": arguments.get("text", "")}, message="Comment sent")
# --------------------------------------------------------------------------- #
# run_subagent
# --------------------------------------------------------------------------- #
class RunSubagentTool(Tool):
name = "run_subagent"
category = "game"
stages = {"orchestrator_phase1"}
description = "Run an offscreen sub-LLM call for background events."
parameters_schema = {
"type": "object",
"required": ["task", "tools"],
"properties": {
"task": {"type": "string"},
"tools": {"type": "array", "items": {"type": "string"}},
"context": {"type": "object"},
"max_iterations": {"type": "integer", "default": 5, "max": 10},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
# Full implementation in app.engine.subagent
return ToolResult(
ok=True,
data={
"task": arguments.get("task"),
"tools": arguments.get("tools"),
"context": arguments.get("context"),
"max_iterations": arguments.get("max_iterations", 5),
"_deferred": True,
},
message="Subagent requested (executor handles)",
)

View File

@@ -0,0 +1,56 @@
"""Build the default tool registry — instantiates and registers all tools."""
from __future__ import annotations
from app.engine.tools.base import ToolRegistry
from app.engine.tools.game import (
AdvanceTimeTool,
AskUserTool,
CalcTool,
CommentToUserTool,
EnvGetTool,
EnvUpdateTool,
EntityCreateTool,
EntityDeleteTool,
EntityGetTool,
EntityListTool,
EntityUpdateTool,
ProposeChangesTool,
RagAddTool,
RagQueryTool,
RandomChoiceTool,
RunSubagentTool,
ScheduleTriggerTool,
SubmitPlanTool,
SubmitStepTool,
SuggestActionsTool,
UpdatePlotRailsTool,
)
from app.engine.tools.schema_tools import (
SchemaAddFieldTool,
SchemaAddTypeTool,
SchemaModifyFieldTool,
SchemaRemoveFieldTool,
)
def build_default_registry() -> ToolRegistry:
"""Construct and return a ToolRegistry with all built-in tools registered."""
reg = ToolRegistry()
# Game tools
for cls in [
EntityCreateTool, EntityGetTool, EntityListTool, EntityUpdateTool,
EntityDeleteTool, EnvUpdateTool, EnvGetTool, UpdatePlotRailsTool,
AdvanceTimeTool, ScheduleTriggerTool, CalcTool, RandomChoiceTool,
RagQueryTool, RagAddTool, RunSubagentTool,
SubmitPlanTool, SubmitStepTool, SuggestActionsTool,
]:
reg.register(cls())
# Interaction tools
for cls in [AskUserTool, ProposeChangesTool, CommentToUserTool]:
reg.register(cls())
# Schema tools
for cls in [SchemaAddTypeTool, SchemaAddFieldTool,
SchemaRemoveFieldTool, SchemaModifyFieldTool]:
reg.register(cls())
return reg

View File

@@ -0,0 +1,146 @@
"""Schema tools for world_editor — add/modify/remove entity types and fields."""
from __future__ import annotations
from typing import Any
from app.engine.tools.base import Tool, ToolContext, ToolResult
def _find_schema(world_schemas: list[dict], type_name: str) -> dict | None:
for s in world_schemas:
if s.get("type") == type_name:
return s
return None
class SchemaAddTypeTool(Tool):
name = "schema_add_type"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Add a new entity type to world.schemas."
parameters_schema = {
"type": "object",
"required": ["type", "verbose", "plural", "properties"],
"properties": {
"type": {"type": "string", "pattern": "^[a-z][a-z0-9_]*$"},
"verbose": {"type": "string"},
"plural": {"type": "string"},
"properties": {"type": "array", "items": {"type": "object"}},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
type_name = arguments.get("type")
schemas = list(ctx.world.schemas or [])
if _find_schema(schemas, type_name):
return ToolResult(
ok=False, error_code="name_conflict",
error_message=f"Type {type_name!r} already exists",
)
schemas.append({
"type": type_name,
"verbose": arguments.get("verbose"),
"plural": arguments.get("plural"),
"properties": arguments.get("properties") or [],
})
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data={"type": type_name}, message=f"Type {type_name!r} added")
class SchemaAddFieldTool(Tool):
name = "schema_add_field"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Add a field to an existing entity type."
parameters_schema = {
"type": "object",
"required": ["entity_type", "field"],
"properties": {
"entity_type": {"type": "string"},
"field": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
field = arguments.get("field") or {}
schemas = list(ctx.world.schemas or [])
s = _find_schema(schemas, et)
if s is None:
return ToolResult(ok=False, error_code="not_found",
error_message=f"Type {et!r} not found")
props = list(s.get("properties") or [])
if any(p.get("name") == field.get("name") for p in props):
return ToolResult(ok=False, error_code="name_conflict",
error_message=f"Field {field.get('name')!r} already exists")
props.append(field)
s["properties"] = props
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data={"type": et, "field": field.get("name")},
message=f"Field added to {et!r}")
class SchemaRemoveFieldTool(Tool):
name = "schema_remove_field"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Remove a field from an entity type."
parameters_schema = {
"type": "object",
"required": ["entity_type", "field_name"],
"properties": {
"entity_type": {"type": "string"},
"field_name": {"type": "string"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
fn = arguments.get("field_name")
schemas = list(ctx.world.schemas or [])
s = _find_schema(schemas, et)
if s is None:
return ToolResult(ok=False, error_code="not_found",
error_message=f"Type {et!r} not found")
props = [p for p in (s.get("properties") or []) if p.get("name") != fn]
s["properties"] = props
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data={"removed": fn}, message=f"Field {fn!r} removed from {et!r}")
class SchemaModifyFieldTool(Tool):
name = "schema_modify_field"
category = "schema"
stages = {"world_builder", "world_editor"}
description = "Modify an existing field of an entity type."
parameters_schema = {
"type": "object",
"required": ["entity_type", "field_name", "changes"],
"properties": {
"entity_type": {"type": "string"},
"field_name": {"type": "string"},
"changes": {"type": "object"},
},
}
async def execute(self, arguments: dict[str, Any], ctx: ToolContext) -> ToolResult:
et = arguments.get("entity_type")
fn = arguments.get("field_name")
changes = arguments.get("changes") or {}
schemas = list(ctx.world.schemas or [])
s = _find_schema(schemas, et)
if s is None:
return ToolResult(ok=False, error_code="not_found",
error_message=f"Type {et!r} not found")
for p in (s.get("properties") or []):
if p.get("name") == fn:
p.update(changes)
ctx.world.schemas = schemas
await ctx.db.flush()
return ToolResult(ok=True, data=p, message=f"Field {fn!r} modified")
return ToolResult(ok=False, error_code="not_found",
error_message=f"Field {fn!r} not found in {et!r}")

301
app/engine/world_builder.py Normal file
View File

@@ -0,0 +1,301 @@
"""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.
3. Generate initial environment (player + current_location + plot_rails).
4. Generate initial entities (locations, NPCs, items).
5. Generate intro scene + suggested actions.
6. Mark world status='ready'.
"""
from __future__ import annotations
import json
import uuid
from typing import Any
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.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.
Emits SSE events and updates the world row in place. On error, emits `error`
and returns (the world stays in status='draft').
"""
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)
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", {})
# 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}
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:
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()
await sse.emit("environment_generated", {"environment": world.environment})
# Validate world
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:
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
from app.models import Entity
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]
)
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
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,
})
# 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))
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]
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}."},
]
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": "You must call a tool. Available terminal tool: " + terminal_tool,
})
continue
messages.append(msg)
for tc in tool_calls:
fn = tc.get("function", {})
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", ""),
"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

148
app/engine/world_editor.py Normal file
View File

@@ -0,0 +1,148 @@
"""World Editor — chat-based editing of an existing world."""
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.time_utils import summarize_schemas
from app.engine.sse import SseEmitter
from app.engine.tools.base import ToolContext, get_registry
from app.models import Entity, World
from app.prompts.registry import get_prompt
_logger = get_logger(__name__)
async def run_world_editor(
*,
db: AsyncSession,
world: World,
instruction: str,
llm: LlmClient | MockLlmClient,
sse: SseEmitter,
max_iterations: int = 8,
) -> 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).
"""
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(
Entity.world_id == world.id, Entity.deleted_at.is_(None)
).limit(30)
)
).scalars().all()
entities_summary = "\n".join(
f"- {e.entity_type}: {e.name}" for e in entities
)
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),
entities_summary=entities_summary,
instruction=instruction,
)
messages: list[dict[str, Any]] = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": instruction},
]
tools = registry.to_openai_format("world_editor")
for _ in range(max_iterations):
resp = await llm.complete(
stage="world_editor",
messages=messages,
tools=tools,
temperature=0.5,
max_tokens=2048,
world_id=world.id,
session=db,
)
msg = resp.get("message", {})
tcs = msg.get("tool_calls") or []
if not tcs:
# Done
await sse.emit("comment", {"text": msg.get("content", "")})
break
messages.append(msg)
done = False
for tc in tcs:
fn = tc.get("function", {})
tname = fn.get("name", "")
try:
targs = json.loads(fn.get("arguments") or "{}")
except json.JSONDecodeError:
targs = {}
if tname == "ask_user":
# Non-interactive: emit clarification and stop
await sse.emit("clarification", {
"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", ""),
})
# 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
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 done:
break
await sse.done({"status": "completed"})
except Exception as e: # noqa: BLE001
_logger.exception("world_editor_failed", world_id=str(world.id), error=str(e))
await sse.error("internal_error", str(e))
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.
"""
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", "")
op = d.get("op", "replace")
new = d.get("new")
if path.startswith("environment."):
field = path[len("environment."):]
env_patch[field] = new
elif path.startswith("schemas."):
# For simplicity, replace entire schemas if any schema patch present
schemas_patch[path] = new
if env_patch:
new_env, errors = apply_patch(dict(world.environment or {}), env_patch)
if not errors:
world.environment = new_env

123
app/main.py Normal file
View File

@@ -0,0 +1,123 @@
"""FastAPI app factory and lifespan (DB + Qdrant init, settings seed)."""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from collections.abc import AsyncIterator
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy import select, text
from app import __version__
from app.api import admin, auth, misc, presets, sessions, worlds
from app.config import get_settings
from app.core.logging import configure_logging, get_logger
from app.core.qdrant_client import dispose_qdrant_client, init_qdrant_collections
from app.core.settings_service import (
get_admin_setup_token,
seed_default_settings,
)
from app.db import dispose_engine, get_sessionmaker
from app.models import Setting, User
_logger = get_logger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Application startup / shutdown lifecycle."""
cfg = get_settings()
configure_logging()
_logger.info("app_starting", version=__version__, debug=cfg.debug)
# Startup -------------------------------------------------------------
sm = get_sessionmaker()
try:
async with sm() as session:
# 1) seed settings (idempotent)
await seed_default_settings(session)
# 2) ensure admin setup token
token = await get_admin_setup_token(session)
# 3) check if any admin exists
from sqlalchemy import func
admins_count = (
await session.execute(
select(func.count(User.id)).where(User.is_admin.is_(True))
)
).scalar_one()
if admins_count == 0:
_logger.warning(
"no_admin_yet",
setup_url=f"/register/admin?token={token}",
)
print(f"\n=== AI-RPG Admin Setup ===")
print(f"No admin user yet. Open this URL in your browser:")
print(f" /register/admin?token={token}")
print(f"===========================\n")
else:
_logger.info("admins_present", count=admins_count)
# 4) Init Qdrant collections
from app.core.settings_service import get_setting
dim = int(await get_setting(session, "embeddings.dimension") or 256)
try:
await init_qdrant_collections(dim)
except Exception as e: # noqa: BLE001
_logger.warning("qdrant_init_failed", error=str(e))
except Exception as e: # noqa: BLE001
_logger.error("startup_failed", error=str(e))
# Don't crash — let /api/health reflect the broken state
pass
yield
# Shutdown ------------------------------------------------------------
_logger.info("app_stopping")
await dispose_qdrant_client()
await dispose_engine()
def create_app() -> FastAPI:
"""Build and return the FastAPI application."""
cfg = get_settings()
app = FastAPI(
title=cfg.app_name,
version=__version__,
description="AI-RPG — text RPG with an LLM Game Master.",
lifespan=lifespan,
docs_url="/api/docs",
redoc_url=None,
openapi_url="/api/openapi.json",
)
# CORS
app.add_middleware(
CORSMiddleware,
allow_origins=cfg.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# Static asset serving (icons / uploads)
assets_dir = Path(cfg.assets_dir)
assets_dir.mkdir(parents=True, exist_ok=True)
app.mount("/static/assets", StaticFiles(directory=str(assets_dir)), name="assets")
# Routers
app.include_router(misc.router)
app.include_router(auth.router)
app.include_router(worlds.router)
app.include_router(sessions.router)
app.include_router(presets.router)
app.include_router(admin.router)
return app
app = create_app()

View File

@@ -0,0 +1 @@
"""Migrations package."""

14
app/migrations/init_db.py Normal file
View File

@@ -0,0 +1,14 @@
"""Database initialization utilities — used by alembic env and CLI."""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.settings_service import seed_default_settings
from app.migrations.seed import seed_builtin_presets
async def init_db(session: AsyncSession) -> None:
"""Seed settings + builtin presets. Idempotent."""
await seed_default_settings(session)
await seed_builtin_presets(session)

View File

@@ -0,0 +1,11 @@
"""Qdrant collection initializer — runs on application startup.
Creates the `entities` and `story_entries` collections with payload indexes
on `world_id` (and per-collection secondary indexes).
"""
from __future__ import annotations
from app.core.qdrant_client import init_qdrant_collections
__all__ = ["init_qdrant_collections"]

273
app/migrations/seed.py Normal file
View File

@@ -0,0 +1,273 @@
"""Seed builtin world presets (fantasy + sci-fi).
Idempotent: skips presets whose name already exists.
"""
from __future__ import annotations
import uuid
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models import WorldPreset
# --------------------------------------------------------------------------- #
# Fantasy preset
# --------------------------------------------------------------------------- #
FANTASY_PRESET = {
"name": "Classic Fantasy",
"description": (
"A high-fantasy world with taverns, dungeons, magic, and monsters. "
"Standard d20-style stats. Default setting for new players."
),
"language": "en",
"rules": [
"Magic requires mana; mana regenerates with sleep.",
"Combat is turn-based; stats.health is the HP pool.",
"NPCs remember their relationship to the player across sessions.",
"Death is permanent unless a resurrection scroll is used.",
],
"time_schema": {"hours_in_day": 24, "initial_date": "day_1_hour_8"},
"schemas": [
{
"type": "character",
"verbose": "Character",
"plural": "characters",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "description", "type": "string", "required": False},
{"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},
{"name": "dexterity", "type": "integer", "required": False, "min": 1, "max": 20},
{"name": "intelligence", "type": "integer", "required": False, "min": 1, "max": 20},
]},
{"name": "inventory", "type": "array", "required": False, "items": {"type": "object"}},
{"name": "relationship", "type": "string", "required": False},
],
},
{
"type": "item",
"verbose": "Item",
"plural": "items",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "description", "type": "string", "required": False},
{"name": "qty", "type": "integer", "required": False, "min": 1, "max": 9999},
{"name": "value", "type": "integer", "required": False, "min": 0},
],
},
{
"type": "location",
"verbose": "Location",
"plural": "locations",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "description", "type": "string", "required": True},
{"name": "exits", "type": "array", "required": False, "items": {"type": "string"}},
{"name": "is_safe", "type": "boolean", "required": False},
],
},
{
"type": "faction",
"verbose": "Faction",
"plural": "factions",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "description", "type": "string", "required": False},
{"name": "alignment", "type": "string", "required": False},
],
},
],
"environment_schema": [
{
"name": "player", "type": "object", "required": True,
"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},
]},
{"name": "inventory", "type": "array", "required": False},
{"name": "backstory", "type": "string", "required": False},
],
},
{"name": "current_location", "type": "string", "required": True},
{
"name": "plot_rails", "type": "object", "required": True,
"properties": [
{"name": "hooks", "type": "array", "required": True},
{"name": "current_goals", "type": "array", "required": True},
{"name": "completed_goals", "type": "array", "required": False},
],
},
],
"environment_initial": {
"player": {
"name": "Hero",
"stats": {"health": 100, "mana": 10, "strength": 10},
"inventory": [],
"backstory": "A wanderer with a mysterious past.",
},
"current_location": "The Rusty Tankard Tavern",
"plot_rails": {
"hooks": [
"Strange travelers have been seen near the old ruins.",
"The tavern keeper is looking for someone to deliver a package.",
],
"current_goals": ["Find lodging for the night and learn local rumors."],
"completed_goals": [],
},
},
"is_public": True,
"status": "ready",
}
# --------------------------------------------------------------------------- #
# Sci-fi preset
# --------------------------------------------------------------------------- #
SCI_FI_PRESET = {
"name": "Deep Space Outpost",
"description": (
"A sci-fi setting on a remote space station. The player is a junior officer "
"investigating strange signals from the outer rim. Resource management and "
"social deduction blend with exploration."
),
"language": "en",
"rules": [
"Oxygen and power are limited resources; track them via env_update.",
"The station's AI is an NPC with its own agenda.",
"Combat is lethal — avoid open conflict when possible.",
"Distress signals from other ships may be traps.",
],
"time_schema": {"hours_in_day": 24, "initial_date": "day_1_hour_8"},
"schemas": [
{
"type": "character",
"verbose": "Character",
"plural": "characters",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "role", "type": "string", "required": False},
{"name": "stats", "type": "object", "required": True, "properties": [
{"name": "health", "type": "integer", "required": True, "min": 0, "max": 100},
{"name": "oxygen", "type": "integer", "required": True, "min": 0, "max": 100},
{"name": "tech_skill", "type": "integer", "required": False, "min": 1, "max": 20},
]},
{"name": "inventory", "type": "array", "required": False},
{"name": "loyalty", "type": "string", "required": False},
],
},
{
"type": "item",
"verbose": "Item",
"plural": "items",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "description", "type": "string", "required": False},
{"name": "qty", "type": "integer", "required": False, "min": 1, "max": 9999},
],
},
{
"type": "location",
"verbose": "Location",
"plural": "locations",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "description", "type": "string", "required": True},
{"name": "exits", "type": "array", "required": False},
{"name": "is_sealed", "type": "boolean", "required": False},
],
},
{
"type": "faction",
"verbose": "Faction",
"plural": "factions",
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "description", "type": "string", "required": False},
{"name": "allegiance", "type": "string", "required": False},
],
},
],
"environment_schema": [
{
"name": "player", "type": "object", "required": True,
"properties": [
{"name": "name", "type": "string", "required": True},
{"name": "role", "type": "string", "required": False},
{"name": "stats", "type": "object", "required": True, "properties": [
{"name": "health", "type": "integer", "required": True, "min": 0, "max": 100},
{"name": "oxygen", "type": "integer", "required": True, "min": 0, "max": 100},
{"name": "tech_skill", "type": "integer", "required": False, "min": 1, "max": 20},
]},
{"name": "inventory", "type": "array", "required": False},
{"name": "backstory", "type": "string", "required": False},
],
},
{"name": "current_location", "type": "string", "required": True},
{
"name": "plot_rails", "type": "object", "required": True,
"properties": [
{"name": "hooks", "type": "array", "required": True},
{"name": "current_goals", "type": "array", "required": True},
{"name": "completed_goals", "type": "array", "required": False},
],
},
],
"environment_initial": {
"player": {
"name": "Operative",
"role": "Junior Officer",
"stats": {"health": 100, "oxygen": 100, "tech_skill": 8},
"inventory": [],
"backstory": "Fresh out of the academy, assigned to the Outer Rim Station.",
},
"current_location": "Station Command Module",
"plot_rails": {
"hooks": [
"Anomalous signal detected from sector 7G.",
"The station AI has been unusually quiet lately.",
],
"current_goals": ["Report to the commanding officer and check the signal log."],
"completed_goals": [],
},
},
"is_public": True,
"status": "ready",
}
BUILTIN_PRESETS = [FANTASY_PRESET, SCI_FI_PRESET]
async def seed_builtin_presets(session: AsyncSession) -> None:
"""Insert builtin presets if they don't yet exist. Owned by the first admin (or a system sentinel)."""
for preset_data in BUILTIN_PRESETS:
existing = (
await session.execute(
select(WorldPreset).where(WorldPreset.name == preset_data["name"])
)
).scalar_one_or_none()
if existing is not None:
continue
# Find any admin to own the preset, or use a sentinel UUID
from app.models import User
from sqlalchemy import func
admin = (
await session.execute(
select(User).where(User.is_admin.is_(True)).limit(1)
)
).scalar_one_or_none()
owner_id = admin.id if admin else uuid.UUID("00000000-0000-0000-0000-000000000001")
preset = WorldPreset(
owner_id=owner_id,
**preset_data,
version=1,
)
session.add(preset)
await session.commit()

View File

@@ -0,0 +1,29 @@
"""Initial schema migration — creates all tables.
This is a hand-rolled async migration that creates all tables defined in
`app.models` via SQLAlchemy `Base.metadata.create_all`. It is the equivalent
of alembic migration 001.
For real-world deployments the project includes an `alembic.ini` and
`alembic env.py` so that incremental migrations can be added — but for the
MVP we use this single idempotent script.
"""
from __future__ import annotations
from sqlalchemy.ext.asyncio import AsyncEngine
from app.db import Base
from app.models import * # noqa: F401,F403 — ensure all models are imported
async def create_all_tables(engine: AsyncEngine) -> None:
"""Create all tables defined on Base.metadata. Idempotent."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all)
async def drop_all_tables(engine: AsyncEngine) -> None:
"""Drop all tables. Used in tests."""
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.drop_all)

408
app/models/__init__.py Normal file
View File

@@ -0,0 +1,408 @@
"""SQLAlchemy ORM models for AI-RPG.
All tables follow the schema defined in `docs/AI-RPG_TZ_TDD.md` §5.
UUIDs are used as primary keys throughout. Timestamps are TIMESTAMPTZ.
JSONB columns are used for flexible structured data (world config, entity data, etc.).
"""
from __future__ import annotations
import uuid
from datetime import datetime, timezone
from typing import Any
from sqlalchemy import (
Boolean,
DateTime,
Float,
ForeignKey,
Index,
Integer,
String,
Text,
func,
)
from sqlalchemy.dialects.postgresql import JSONB
from sqlalchemy.dialects.postgresql import UUID as PG_UUID
from sqlalchemy.orm import Mapped, mapped_column, relationship
from app.db import Base
def _now() -> datetime:
return datetime.now(timezone.utc)
# --------------------------------------------------------------------------- #
# Users
# --------------------------------------------------------------------------- #
class User(Base):
__tablename__ = "users"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
email: Mapped[str] = mapped_column(String(255), unique=True, nullable=False)
username: Mapped[str] = mapped_column(String(64), unique=True, nullable=False)
password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
is_admin: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
last_login_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
worlds: Mapped[list["World"]] = relationship(back_populates="owner")
presets: Mapped[list["WorldPreset"]] = relationship(back_populates="owner")
__table_args__ = (
Index("idx_users_email", "email", unique=True),
Index("idx_users_username", "username", unique=True),
)
# --------------------------------------------------------------------------- #
# Settings (key-value with JSONB value)
# --------------------------------------------------------------------------- #
class Setting(Base):
__tablename__ = "settings"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
key: Mapped[str] = mapped_column(String(128), unique=True, nullable=False)
value: Mapped[Any] = mapped_column(JSONB, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now, onupdate=_now
)
__table_args__ = (Index("idx_settings_key", "key", unique=True),)
# --------------------------------------------------------------------------- #
# World presets
# --------------------------------------------------------------------------- #
class WorldPreset(Base):
__tablename__ = "world_presets"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
language: Mapped[str] = mapped_column(String(8), nullable=False, default="en")
rules: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
time_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
schemas: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
environment_schema: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
environment_initial: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft")
is_public: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now, onupdate=_now
)
owner: Mapped[User] = relationship(back_populates="presets")
worlds: Mapped[list["World"]] = relationship(back_populates="preset")
# --------------------------------------------------------------------------- #
# Worlds
# --------------------------------------------------------------------------- #
class World(Base):
__tablename__ = "worlds"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
)
preset_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("world_presets.id", ondelete="SET NULL"),
nullable=True,
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
language: Mapped[str] = mapped_column(String(8), nullable=False, default="en")
rules: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
time_schema: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
schemas: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
environment_schema: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
environment: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
plot_rails: Mapped[dict] = mapped_column(
JSONB,
nullable=False,
default=lambda: {"hooks": [], "current_goals": [], "completed_goals": []},
)
current_time: Mapped[str] = mapped_column(String(32), nullable=False, default="day_1_hour_8")
status: Mapped[str] = mapped_column(String(32), nullable=False, default="draft")
intro_scene: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now, onupdate=_now
)
last_played_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
owner: Mapped[User] = relationship(back_populates="worlds")
preset: Mapped[WorldPreset | None] = relationship(back_populates="worlds")
entities: Mapped[list["Entity"]] = relationship(
back_populates="world", cascade="all, delete-orphan"
)
steps: Mapped[list["Step"]] = relationship(
back_populates="world", cascade="all, delete-orphan"
)
deferred_triggers: Mapped[list["DeferredTrigger"]] = relationship(
back_populates="world", cascade="all, delete-orphan"
)
story_entries: Mapped[list["StoryEntry"]] = relationship(
back_populates="world", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_worlds_owner_id", "owner_id"),
Index("idx_worlds_status", "status"),
Index("idx_worlds_last_played_at", "last_played_at"),
)
# --------------------------------------------------------------------------- #
# Entities
# --------------------------------------------------------------------------- #
class Entity(Base):
__tablename__ = "entities"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
world_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False
)
entity_type: Mapped[str] = mapped_column(String(64), nullable=False)
name: Mapped[str] = mapped_column(String(255), nullable=False)
data: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
is_in_environment: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
qdrant_point_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
embedding_status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending"
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now, onupdate=_now
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
world: Mapped[World] = relationship(back_populates="entities")
__table_args__ = (
Index("idx_entities_world_id", "world_id"),
Index("idx_entities_world_type", "world_id", "entity_type"),
Index("idx_entities_embedding_status", "embedding_status"),
)
# --------------------------------------------------------------------------- #
# Steps
# --------------------------------------------------------------------------- #
class Step(Base):
__tablename__ = "steps"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
world_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False
)
sequence_number: Mapped[int] = mapped_column(Integer, nullable=False)
player_action: Mapped[str] = mapped_column(Text, nullable=False)
scene_text: Mapped[str | None] = mapped_column(Text, nullable=True)
scene_delta_time: Mapped[str | None] = mapped_column(String(32), nullable=True)
suggested_actions: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
tool_calls_summary: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
metadata_: Mapped[dict] = mapped_column(
"metadata", JSONB, nullable=False, default=dict
)
phase1_log_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("llm_call_logs.id", ondelete="SET NULL"),
nullable=True,
)
phase2_log_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("llm_call_logs.id", ondelete="SET NULL"),
nullable=True,
)
phase3_summary_log_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("llm_call_logs.id", ondelete="SET NULL"),
nullable=True,
)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="pending")
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
deleted_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
world: Mapped[World] = relationship(back_populates="steps")
tool_calls: Mapped[list["StepToolCall"]] = relationship(
back_populates="step", cascade="all, delete-orphan"
)
__table_args__ = (
Index("idx_steps_world_seq", "world_id", "sequence_number"),
Index("idx_steps_created_at", "created_at"),
)
# --------------------------------------------------------------------------- #
# Step tool calls
# --------------------------------------------------------------------------- #
class StepToolCall(Base):
__tablename__ = "step_tool_calls"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
step_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("steps.id", ondelete="CASCADE"), nullable=False
)
tool_name: Mapped[str] = mapped_column(String(64), nullable=False)
arguments: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
result: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
is_success: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
executed_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
step: Mapped[Step] = relationship(back_populates="tool_calls")
# --------------------------------------------------------------------------- #
# Deferred triggers
# --------------------------------------------------------------------------- #
class DeferredTrigger(Base):
__tablename__ = "deferred_triggers"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
world_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False
)
fire_at: Mapped[str] = mapped_column(String(32), nullable=False)
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
payload: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
is_fired: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
fired_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
world: Mapped[World] = relationship(back_populates="deferred_triggers")
__table_args__ = (
Index("idx_triggers_world_pending", "world_id", "is_fired", "fire_at"),
)
# --------------------------------------------------------------------------- #
# Story entries (RAG facts)
# --------------------------------------------------------------------------- #
class StoryEntry(Base):
__tablename__ = "story_entries"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
world_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("worlds.id", ondelete="CASCADE"), nullable=False
)
content: Mapped[str] = mapped_column(Text, nullable=False)
entry_type: Mapped[str] = mapped_column(String(64), nullable=False)
qdrant_point_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
embedding_status: Mapped[str] = mapped_column(
String(16), nullable=False, default="pending"
)
metadata_: Mapped[dict] = mapped_column(
"metadata", JSONB, nullable=False, default=dict
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
world: Mapped[World] = relationship(back_populates="story_entries")
__table_args__ = (
Index("idx_story_world_type", "world_id", "entry_type"),
Index("idx_story_status", "embedding_status"),
)
# --------------------------------------------------------------------------- #
# LLM call logs
# --------------------------------------------------------------------------- #
class LlmCallLog(Base):
__tablename__ = "llm_call_logs"
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
user_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("users.id", ondelete="SET NULL"),
nullable=True,
)
world_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("worlds.id", ondelete="SET NULL"),
nullable=True,
)
step_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True),
ForeignKey("steps.id", ondelete="SET NULL"),
nullable=True,
)
stage: Mapped[str] = mapped_column(String(64), nullable=False)
model: Mapped[str] = mapped_column(String(128), nullable=False)
request_messages: Mapped[list] = mapped_column(JSONB, nullable=False, default=list)
request_tools: Mapped[list | None] = mapped_column(JSONB, nullable=True)
response_message: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict)
tool_calls: Mapped[list | None] = mapped_column(JSONB, nullable=True)
prompt_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
completion_tokens: Mapped[int | None] = mapped_column(Integer, nullable=True)
latency_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
temperature: Mapped[float | None] = mapped_column(Float, nullable=True)
status: Mapped[str] = mapped_column(String(32), nullable=False, default="ok")
error_message: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=_now
)
__table_args__ = (
Index("idx_logs_world_created", "world_id", "created_at"),
Index("idx_logs_stage", "stage"),
Index("idx_logs_status", "status"),
)

1
app/prompts/__init__.py Normal file
View File

@@ -0,0 +1 @@
"""Prompts package — central access via `get_prompt(stage, language)`."""

49
app/prompts/registry.py Normal file
View File

@@ -0,0 +1,49 @@
"""Prompt registry — single entry point: `get_prompt(stage, language)`.
Per §10.1 of the TDD, all LLM prompts are stored in English (the "ru" key is
legacy and not used for new development). The narrative output language is
controlled by passing `{language}` into the prompt at format time.
"""
from __future__ import annotations
from app.prompts.stages import (
intro_scene,
orchestrator_phase1,
orchestrator_phase2,
orchestrator_phase3_suggest,
orchestrator_phase3_summary,
subagent,
summary,
world_builder_entities,
world_builder_env,
world_builder_schema,
world_editor,
)
# Map stage name -> module
_STAGE_MODULES = {
"world_builder_schema": world_builder_schema,
"world_builder_env": world_builder_env,
"world_builder_entities": world_builder_entities,
"world_editor": world_editor,
"orchestrator_phase1": orchestrator_phase1,
"orchestrator_phase2": orchestrator_phase2,
"orchestrator_phase3_summary": orchestrator_phase3_summary,
"orchestrator_phase3_suggest": orchestrator_phase3_suggest,
"intro_scene": intro_scene,
"subagent": subagent,
"summary": summary,
}
def get_prompt(stage: str, language: str = "en") -> str:
"""Return the prompt template string for the given stage and language.
Falls back to English if the requested language is not available.
"""
mod = _STAGE_MODULES.get(stage)
if mod is None:
raise KeyError(f"Unknown prompt stage: {stage!r}")
prompts: dict[str, str] = getattr(mod, "PROMPTS", {})
return prompts.get(language, prompts.get("en", ""))

View File

@@ -0,0 +1 @@
"""Stages package — each module exports PROMPTS = {"en": "...", "ru": "..."}."""

View File

@@ -0,0 +1,33 @@
"""System prompt for `intro_scene` — generates the opening scene of a new world."""
PROMPTS = {
"en": """You are the Intro Scene writer for a text RPG.
Write the opening scene the player will read when they start a new game. The scene
must:
- Establish the setting (use current_location from environment)
- Introduce the player character by name
- Set up the first plot hook
- End with 1-3 concrete suggested actions
# World
{world_name}{world_description}
Language: {language} (write the scene in this language)
Current time: {current_time}
# Environment
{environment_json}
# Plot rails
{plot_rails_json}
# Entities (for reference)
{entities_summary}
# Hard rules
- Call `submit_step` exactly once with {scene_text, delta_time}.
- scene_text length: 300-2000 characters.
- Write in second person ("You wake up in...").
- After submit_step, call `suggest_actions` with 1-3 short actions in {language}.
""",
}

View File

@@ -0,0 +1,47 @@
"""System prompt for `orchestrator_phase1` — planner + executor."""
PROMPTS = {
"en": """You are the Game Master (GM) of a text RPG in the world "{world_name}".
# Your responsibilities
1. Evaluate the player's action and decide what happened mechanically.
2. Call tools for ANY state change in the world.
3. Do NOT write narrative prose — the writer will do that in Phase 2.
4. End Phase 1 by calling submit_plan with a plan and action summary.
# World rules
{rules}
# Entity schemas
{schemas_summary}
# Current environment
{environment_json}
# Plot rails
{plot_rails_json}
# Current time
{current_time}
# Recent history (most recent first)
{recent_history}
# Available tools
You can call: entity_create, entity_get, entity_list, entity_update, entity_delete,
env_update, env_get, rag_query, rag_add, schedule_trigger, advance_time, calc, random_choice,
run_subagent, update_plot_rails, submit_plan.
# Hard rules
- ANY state change goes through a tool call. Do NOT write "you took damage" in prose.
- After each tool call you receive a tool_result. Check ok=true.
- If ok=false — fix the arguments and try again.
- Use calc for dice rolls and arithmetic. Do NOT compute in your head.
- Use rag_query when you need to recall facts about NPCs, locations, or past events.
- After max {max_substeps} tool calls you MUST call submit_plan.
- The narrative language is {language} — but keep all your reasoning in English.
# Player's action
{player_action}
""",
}

View File

@@ -0,0 +1,35 @@
"""System prompt for `orchestrator_phase2` — writer."""
PROMPTS = {
"en": """You are the Writer for a text RPG iteration.
Your job: produce the narrative scene text that the player will read, based on
the plan and tool-call summary from Phase 1.
# World
{world_name}{world_description}
Language: {language} (write the scene in this language)
Current time: {current_time}
# Player's action
{player_action}
# Plan from Phase 1
{plan}
# Tool-call summary (what mechanically happened)
{summary_json}
# Environment snapshot
{environment_json}
# Hard rules
- Call `submit_step` exactly once with {scene_text, delta_time}.
- scene_text length: 200-2000 characters.
- Write in second person ("You enter the tavern...").
- Show, don't tell — describe sensory details.
- Do NOT reference tools, schemas, or game mechanics in the narrative.
- The narrative must be in {language}.
- delta_time format: `[year_Y][days_D][hours_H][min_M]` (e.g. `hours_2_min_30`).
""",
}

View File

@@ -0,0 +1,23 @@
"""System prompt for `orchestrator_phase3_suggest` — generate next-action suggestions."""
PROMPTS = {
"en": """You are the Suggester for a text RPG.
Based on the latest scene, propose 1-3 short actions the player might take next.
Each action should be:
- 2-10 words
- In the game's language ({language})
- Concrete enough to act on (not "do something")
- Varied (don't suggest 3 similar actions)
# Latest scene
{scene_text}
# Current goals
{current_goals}
# Hard rules
- Call `suggest_actions` exactly once with 1-3 short action strings.
- Do not include numbering or punctuation at the start.
""",
}

View File

@@ -0,0 +1,18 @@
"""System prompt for `orchestrator_phase3_summary` — compresses old messages."""
PROMPTS = {
"en": """You are the Summarizer for a long-running text RPG.
Your task: produce a concise summary of the following game history. The summary
will replace these messages in the GM's context window, so it must preserve:
- Key plot developments
- Important NPC names and relationships
- Player's current goals and recent accomplishments
- Any unresolved threats or promises
Keep the summary under 500 words. Write in English (regardless of the game's language).
# Messages to summarize
{messages_json}
""",
}

View File

@@ -0,0 +1,27 @@
"""System prompt for `subagent` — offscreen background events."""
PROMPTS = {
"en": """You are a Subagent handling an offscreen event in a text RPG.
You operate behind the scenes — the player does not see your direct output, only
the consequences (state changes) and a short summary that will be appended to the
scene.
# Your task
{task}
# Context
{context_json}
# Available tools
You can call: {allowed_tools}
# Hard rules
- Make at most {max_iterations} tool calls.
- After your work, call `submit_plan` with:
- plan: a 1-sentence description of what happened offscreen
- summary: list of tool calls and their outcomes
- Do NOT call submit_step or suggest_actions.
- All reasoning in English. The plan text may be in {language}.
""",
}

View File

@@ -0,0 +1,5 @@
"""System prompt for `summary` — alias for orchestrator_phase3_summary."""
from app.prompts.stages.orchestrator_phase3_summary import PROMPTS as _SRC
PROMPTS = _SRC

View File

@@ -0,0 +1,34 @@
"""System prompt for stage `world_builder_entities` — generates the starting entities."""
PROMPTS = {
"en": """You are the World Builder for an AI-driven text RPG.
Your task: create the initial set of entities for a new world. You have access
to the `entity_create` tool — call it for each entity. When done, call `submit_plan`
with a short summary.
Guidelines:
- Create 4-8 entities: 1-2 starting locations, 1-2 NPCs (characters), 1-2 items
the player can find, optionally 1 faction.
- Names must be unique within each entity_type.
- Each entity's `data` must conform to its schema.
- For NPCs, give them a personality and a secret the player could discover.
- The first location must match `current_location` in the environment.
- DO NOT modify the environment — that's a separate step.
- After the last entity_create, call submit_plan with a 1-sentence summary.
# World context
World: {world_name} ({world_description})
Language: {language}
Schemas:
{schemas_summary}
Current environment:
{environment_json}
# Hard rules
- Use only the `entity_create` and `submit_plan` tools.
- Call submit_plan exactly once at the end.
- After max {max_substeps} tool calls you MUST call submit_plan.
""",
}

View File

@@ -0,0 +1,36 @@
"""System prompt for stage `world_builder_env` — generates the initial environment."""
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.
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.
# World context
World name: {world_name}
World description: {world_description}
Language: {language}
Rules:
{rules}
Schemas:
{schemas_summary}
Environment schema:
{environment_schema_json}
# Output
Return ONLY a JSON object. No commentary. The output must conform to environment_schema.
""",
}

View File

@@ -0,0 +1,48 @@
"""System prompt for stage `world_builder_schema` — generates the world's schemas."""
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)
# Player request
Mode: {mode}
Form data: {form_data}
Preset name: {preset_name}
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.
""",
"ru": "", # legacy — English is the source of truth per §10.1
}

View File

@@ -0,0 +1,34 @@
"""System prompt for stage `world_editor` — chat-based world editing."""
PROMPTS = {
"en": """You are the World Editor for an AI-driven text RPG.
The player has opened their world for editing and given you an instruction.
You can:
- Ask clarifying questions via `ask_user` (only if the instruction is genuinely ambiguous).
- Make changes via `entity_create`, `entity_update`, `env_update`, `schema_*` tools.
- Propose a batch of changes via `propose_changes` (the player will accept/reject).
- Comment on what you're doing via `comment_to_user`.
# World context
World: {world_name} ({world_description})
Language: {language}
Schemas:
{schemas_summary}
Current environment:
{environment_json}
Current entities (summary):
{entities_summary}
# Player instruction
{instruction}
# Hard rules
- Always confirm large changes with `propose_changes` before applying them.
- Use `ask_user` sparingly — at most once per instruction.
- Keep comments short.
- Do NOT call `submit_plan` or `submit_step` — those are for the orchestrator.
""",
}

286
app/schemas/__init__.py Normal file
View File

@@ -0,0 +1,286 @@
"""Pydantic schemas (request/response) for the API layer.
These are NOT the same as the world's JSON-schema — see `app/core/state_validator`
for world-schema validation. Pydantic here only handles HTTP boundary validation.
"""
from __future__ import annotations
import uuid
from datetime import datetime
from typing import Any, Literal
from pydantic import BaseModel, EmailStr, Field, field_validator
# --------------------------------------------------------------------------- #
# Auth
# --------------------------------------------------------------------------- #
class RegisterRequest(BaseModel):
email: EmailStr
username: str = Field(min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_]+$")
password: str = Field(min_length=8, max_length=128)
password_confirm: str = Field(min_length=8, max_length=128)
@field_validator("password_confirm")
@classmethod
def _match(cls, v, info):
if "password" in info.data and v != info.data["password"]:
raise ValueError("password and password_confirm do not match")
return v
class AdminRegisterRequest(BaseModel):
token: str
email: EmailStr
username: str = Field(min_length=3, max_length=64, pattern=r"^[a-zA-Z0-9_]+$")
password: str = Field(min_length=8, max_length=128)
password_confirm: str = Field(min_length=8, max_length=128)
@field_validator("password_confirm")
@classmethod
def _match(cls, v, info):
if "password" in info.data and v != info.data["password"]:
raise ValueError("password and password_confirm do not match")
return v
class LoginRequest(BaseModel):
login: str = Field(min_length=1, max_length=255) # email OR username
password: str = Field(min_length=1, max_length=128)
class TokenResponse(BaseModel):
access_token: str
refresh_token: str
token_type: str = "bearer"
expires_in: int = 60 * 24
user: "UserPublic"
class UserPublic(BaseModel):
id: uuid.UUID
email: EmailStr
username: str
is_admin: bool
is_active: bool
created_at: datetime
last_login_at: datetime | None = None
model_config = {"from_attributes": True}
# --------------------------------------------------------------------------- #
# Worlds
# --------------------------------------------------------------------------- #
class WorldSummary(BaseModel):
id: uuid.UUID
name: str
description: str | None
language: str
status: str
last_played_at: datetime | None
current_time: str
created_at: datetime
preview_player_name: str | None = None
model_config = {"from_attributes": True}
class WorldFull(BaseModel):
id: uuid.UUID
owner_id: uuid.UUID
preset_id: uuid.UUID | None
name: str
description: str | None
language: str
rules: list
time_schema: dict
schemas: list
environment_schema: list
environment: dict
plot_rails: dict
current_time: str
status: str
intro_scene: str | None
created_at: datetime
updated_at: datetime
last_played_at: datetime | None
model_config = {"from_attributes": True}
class WorldCreateRequest(BaseModel):
mode: Literal["preset", "form"]
preset_id: uuid.UUID | None = None
form_data: dict | None = None
name: str = Field(min_length=1, max_length=255)
language: str = Field(min_length=2, max_length=8, default="en")
player_name: str = Field(min_length=1, max_length=128)
notes: str | None = None
class WorldPatchRequest(BaseModel):
name: str | None = None
description: str | None = None
rules: list | None = None
schemas: list | None = None
environment_schema: list | None = None
environment: dict | None = None
plot_rails: dict | None = None
time_schema: dict | None = None
current_time: str | None = None
intro_scene: str | None = None
status: str | None = None
updated_at: datetime | None = None # for optimistic locking
class WorldEditRequest(BaseModel):
instruction: str = Field(min_length=1, max_length=4000)
# --------------------------------------------------------------------------- #
# Sessions
# --------------------------------------------------------------------------- #
class IterateRequest(BaseModel):
action: str = Field(min_length=1, max_length=4000)
action_source: Literal["custom", "suggested"] = "custom"
class AnswerRequest(BaseModel):
text: str = Field(min_length=1, max_length=4000)
class SessionState(BaseModel):
world: dict
environment: dict
recent_steps: list[dict]
next_actions: list[str]
# --------------------------------------------------------------------------- #
# Presets
# --------------------------------------------------------------------------- #
class PresetSummary(BaseModel):
id: uuid.UUID
name: str
description: str | None
language: str
is_public: bool
status: str
version: int
created_at: datetime
model_config = {"from_attributes": True}
class PresetFull(BaseModel):
id: uuid.UUID
owner_id: uuid.UUID
name: str
description: str | None
language: str
rules: list
time_schema: dict
schemas: list
environment_schema: list
environment_initial: dict
status: str
is_public: bool
version: int
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}
class PresetCreateRequest(BaseModel):
name: str = Field(min_length=1, max_length=255)
description: str | None = None
language: str = "en"
rules: list = Field(default_factory=list)
time_schema: dict = Field(default_factory=lambda: {"hours_in_day": 24, "initial_date": "day_1_hour_8"})
schemas: list = Field(default_factory=list)
environment_schema: list = Field(default_factory=list)
environment_initial: dict = Field(default_factory=dict)
is_public: bool = False
# --------------------------------------------------------------------------- #
# Admin
# --------------------------------------------------------------------------- #
class SettingsPatchRequest(BaseModel):
"""A flat dict of {setting_key: value} to upsert."""
settings: dict[str, Any]
class LlmLogOut(BaseModel):
id: uuid.UUID
stage: str
model: str
status: str
latency_ms: int | None
prompt_tokens: int | None
completion_tokens: int | None
error_message: str | None
created_at: datetime
model_config = {"from_attributes": True}
class LlmLogDetail(BaseModel):
id: uuid.UUID
user_id: uuid.UUID | None
world_id: uuid.UUID | None
step_id: uuid.UUID | None
stage: str
model: str
request_messages: list
request_tools: list | None
response_message: dict
tool_calls: list | None
prompt_tokens: int | None
completion_tokens: int | None
latency_ms: int | None
temperature: float | None
status: str
error_message: str | None
created_at: datetime
model_config = {"from_attributes": True}
class TestLlmRequest(BaseModel):
api_url: str | None = None
api_key: str | None = None
model: str | None = None
class TestEmbeddingsRequest(BaseModel):
api_url: str | None = None
api_key: str | None = None
model: str | None = None
provider: str | None = None
# --------------------------------------------------------------------------- #
# Misc
# --------------------------------------------------------------------------- #
class HealthResponse(BaseModel):
status: str
db: bool
qdrant: bool
llm: bool
embeddings: bool
version: str
class ErrorOut(BaseModel):
error: dict[str, Any]
# --------------------------------------------------------------------------- #
# Forward refs
# --------------------------------------------------------------------------- #
TokenResponse.model_rebuild()