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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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