rebase
This commit is contained in:
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
|
||||
Reference in New Issue
Block a user