rebase
This commit is contained in:
3
app/__init__.py
Normal file
3
app/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""AI-RPG backend application package."""
|
||||
|
||||
__version__ = "1.0.0"
|
||||
1
app/api/__init__.py
Normal file
1
app/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""API package."""
|
||||
401
app/api/admin.py
Normal file
401
app/api/admin.py
Normal 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
224
app/api/auth.py
Normal 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
64
app/api/deps.py
Normal 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
74
app/api/misc.py
Normal 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
125
app/api/presets.py
Normal 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
348
app/api/sessions.py
Normal 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
182
app/api/worlds.py
Normal 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
125
app/config.py
Normal 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
1
app/core/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Empty package marker."""
|
||||
153
app/core/embeddings.py
Normal file
153
app/core/embeddings.py
Normal 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
462
app/core/llm.py
Normal 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
48
app/core/logging.py
Normal 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
130
app/core/qdrant_client.py
Normal 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
326
app/core/rag.py
Normal 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
85
app/core/security.py
Normal 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
|
||||
187
app/core/settings_service.py
Normal file
187
app/core/settings_service.py
Normal 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
307
app/core/state_validator.py
Normal 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
148
app/core/time_utils.py
Normal 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
70
app/db.py
Normal 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
1
app/engine/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Engine layer — game logic (orchestrator, world builder/editor, tools, context)."""
|
||||
180
app/engine/context.py
Normal file
180
app/engine/context.py
Normal 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
314
app/engine/game_master.py
Normal 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
84
app/engine/sse.py
Normal 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()
|
||||
1
app/engine/tools/__init__.py
Normal file
1
app/engine/tools/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Tools package — game tools, interaction tools, schema tools."""
|
||||
231
app/engine/tools/base.py
Normal file
231
app/engine/tools/base.py
Normal 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
993
app/engine/tools/game.py
Normal 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)",
|
||||
)
|
||||
56
app/engine/tools/register_all.py
Normal file
56
app/engine/tools/register_all.py
Normal 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
|
||||
146
app/engine/tools/schema_tools.py
Normal file
146
app/engine/tools/schema_tools.py
Normal 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
301
app/engine/world_builder.py
Normal 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
148
app/engine/world_editor.py
Normal 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
123
app/main.py
Normal 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()
|
||||
1
app/migrations/__init__.py
Normal file
1
app/migrations/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Migrations package."""
|
||||
14
app/migrations/init_db.py
Normal file
14
app/migrations/init_db.py
Normal 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)
|
||||
11
app/migrations/init_qdrant.py
Normal file
11
app/migrations/init_qdrant.py
Normal 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
273
app/migrations/seed.py
Normal 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()
|
||||
29
app/migrations/versions/001_initial_schema.py
Normal file
29
app/migrations/versions/001_initial_schema.py
Normal 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
408
app/models/__init__.py
Normal 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
1
app/prompts/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Prompts package — central access via `get_prompt(stage, language)`."""
|
||||
49
app/prompts/registry.py
Normal file
49
app/prompts/registry.py
Normal 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", ""))
|
||||
1
app/prompts/stages/__init__.py
Normal file
1
app/prompts/stages/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Stages package — each module exports PROMPTS = {"en": "...", "ru": "..."}."""
|
||||
33
app/prompts/stages/intro_scene.py
Normal file
33
app/prompts/stages/intro_scene.py
Normal 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}.
|
||||
""",
|
||||
}
|
||||
47
app/prompts/stages/orchestrator_phase1.py
Normal file
47
app/prompts/stages/orchestrator_phase1.py
Normal 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}
|
||||
""",
|
||||
}
|
||||
35
app/prompts/stages/orchestrator_phase2.py
Normal file
35
app/prompts/stages/orchestrator_phase2.py
Normal 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`).
|
||||
""",
|
||||
}
|
||||
23
app/prompts/stages/orchestrator_phase3_suggest.py
Normal file
23
app/prompts/stages/orchestrator_phase3_suggest.py
Normal 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.
|
||||
""",
|
||||
}
|
||||
18
app/prompts/stages/orchestrator_phase3_summary.py
Normal file
18
app/prompts/stages/orchestrator_phase3_summary.py
Normal 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}
|
||||
""",
|
||||
}
|
||||
27
app/prompts/stages/subagent.py
Normal file
27
app/prompts/stages/subagent.py
Normal 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}.
|
||||
""",
|
||||
}
|
||||
5
app/prompts/stages/summary.py
Normal file
5
app/prompts/stages/summary.py
Normal 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
|
||||
34
app/prompts/stages/world_builder_entities.py
Normal file
34
app/prompts/stages/world_builder_entities.py
Normal 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.
|
||||
""",
|
||||
}
|
||||
36
app/prompts/stages/world_builder_env.py
Normal file
36
app/prompts/stages/world_builder_env.py
Normal 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.
|
||||
""",
|
||||
}
|
||||
48
app/prompts/stages/world_builder_schema.py
Normal file
48
app/prompts/stages/world_builder_schema.py
Normal 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
|
||||
}
|
||||
34
app/prompts/stages/world_editor.py
Normal file
34
app/prompts/stages/world_editor.py
Normal 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
286
app/schemas/__init__.py
Normal 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()
|
||||
Reference in New Issue
Block a user