This commit is contained in:
Mikan
2026-06-21 02:41:48 +03:00
parent c21a13d2a3
commit bd85e186dc
31 changed files with 1372 additions and 319 deletions

View File

@@ -152,6 +152,34 @@ async def patch_user(
return {"id": str(user.id), "is_admin": user.is_admin, "is_active": user.is_active}
@router.delete("/worlds/{world_id}", status_code=200)
async def hard_delete_world(
world_id: uuid.UUID,
db: AsyncSession = Depends(get_db),
_user: User = Depends(require_admin),
) -> dict:
"""Hard-delete a world (cascade deletes all related entities, steps, logs).
Also cleans up Qdrant points for the world (best-effort).
"""
from app.models import World
from app.core.qdrant_client import cleanup_world_points
world = (
await db.execute(select(World).where(World.id == world_id))
).scalar_one_or_none()
if world is None:
raise HTTPException(404, "not_found")
await db.delete(world)
await db.commit()
# Best-effort Qdrant cleanup
try:
await cleanup_world_points(str(world_id))
except Exception as e: # noqa: BLE001
_logger.warning("qdrant_cleanup_failed", world_id=str(world_id), error=str(e))
return {"ok": True, "deleted": str(world_id)}
# --------------------------------------------------------------------------- #
# Stats
# --------------------------------------------------------------------------- #
@@ -163,14 +191,27 @@ async def stats(
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()
# Active worlds (exclude archived)
active_worlds = (
await db.execute(
select(func.count(World.id)).where(World.status != "archived")
)
).scalar_one()
archived_worlds = (
await db.execute(
select(func.count(World.id)).where(World.status == "archived")
)
).scalar_one()
total_worlds = active_worlds + archived_worlds
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,
"worlds": active_worlds,
"worlds_total": total_worlds,
"worlds_archived": archived_worlds,
"steps": steps_count,
"avg_llm_latency_ms": float(avg_latency) if avg_latency else 0,
}

View File

@@ -1,4 +1,4 @@
"""Misc endpoints: health, i18n."""
"""Misc endpoints: health, i18n, public settings."""
from __future__ import annotations
@@ -11,6 +11,7 @@ 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.core.settings_service import get_all_settings, get_setting
from app.db import get_db
from app.schemas import HealthResponse
@@ -34,12 +35,9 @@ async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse:
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:
@@ -59,16 +57,28 @@ async def health(db: AsyncSession = Depends(get_db)) -> HealthResponse:
)
@router.get("/settings/public")
async def public_settings(db: AsyncSession = Depends(get_db)) -> dict:
"""Return UI-relevant settings (no secrets). No auth required.
The frontend uses this on app load to set the page title, favicon,
and logo.
"""
page_title = await get_setting(db, "ui.page_title")
favicon_url = await get_setting(db, "ui.favicon_url")
logo_url = await get_setting(db, "ui.logo_url")
og_image_url = await get_setting(db, "ui.og_image_url")
return {
"page_title": page_title or "AI-RPG",
"favicon_url": favicon_url or "/icon.png",
"logo_url": logo_url or "/icon.png",
"og_image_url": og_image_url or "",
}
@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.
"""
"""Return translation JSON for the given language."""
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}

View File

@@ -339,8 +339,18 @@ def _format_sse(evt: dict[str, str]) -> str:
return "\n".join(lines)
from contextlib import asynccontextmanager
@asynccontextmanager
async def _session_scope():
"""Open a fresh DB session for the background task."""
"""Open a fresh DB session for the background task.
Must be used as: async with _session_scope() as bg_db: ...
The @asynccontextmanager decorator is required — without it, an
`async def` with `yield` returns an async generator, which does NOT
support `async with`.
"""
from app.db import get_sessionmaker
sm = get_sessionmaker()

View File

@@ -49,9 +49,16 @@ async def list_worlds(
db: AsyncSession = Depends(get_db),
user: User = Depends(get_current_user),
) -> dict:
"""List the current user's worlds."""
"""List the current user's worlds.
By default, archived worlds are excluded. Pass status_filter='archived'
to see only archived, or status_filter='all' to see everything.
"""
stmt = select(World).where(World.owner_id == user.id)
if status_filter and status_filter != "all":
if not status_filter or status_filter == "active":
# Default: exclude archived
stmt = stmt.where(World.status != "archived")
elif 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())

View File

@@ -141,16 +141,36 @@ class LlmClient:
)
if resp.status_code >= 500:
raise LLMUnavailableError(
f"LLM provider returned {resp.status_code}: {resp.text[:200]}"
f"LLM provider returned HTTP {resp.status_code}: {resp.text[:300]}"
)
if resp.status_code == 429:
raise LLMUnavailableError("LLM provider rate-limited (429)")
raise LLMUnavailableError("LLM provider rate-limited (HTTP 429)")
if resp.status_code >= 400:
# Try to extract error message from JSON body
err_body = resp.text[:500]
try:
err_json = resp.json()
if "error" in err_json:
err_msg = err_json["error"].get("message", err_body)
else:
err_msg = err_body
except Exception: # noqa: BLE001
err_msg = err_body
raise LLMResponseError(
f"LLM provider returned {resp.status_code}: {resp.text[:500]}",
f"LLM provider returned HTTP {resp.status_code}: {err_msg}",
code="api_error",
)
data = resp.json()
# Parse JSON response — if this fails, the URL is likely wrong
# (pointing at an HTML page instead of an OpenAI-compatible API)
try:
data = resp.json()
except Exception as e: # noqa: BLE001
raise LLMResponseError(
f"LLM provider returned non-JSON response (check that "
f"api_url points to an OpenAI-compatible endpoint). "
f"First 200 chars: {resp.text[:200]}",
code="parse_error",
) from e
break
except (httpx.TimeoutException, asyncio.TimeoutError) as e:
last_exc = LLMTimeoutError(str(e))

View File

@@ -27,6 +27,25 @@ from app.models import Setting, User
_logger = get_logger(__name__)
async def _apply_schema_fixups(engine) -> None:
"""Apply idempotent ALTER statements for schema fixes that create_all
cannot handle (e.g. changing NOT NULL → NULL on existing tables).
These run on every startup. Each statement is wrapped in try/except so
it silently succeeds if the fix was already applied.
"""
fixups = [
# world_presets.owner_id: was NOT NULL, now nullable (for system presets)
"ALTER TABLE world_presets ALTER COLUMN owner_id DROP NOT NULL",
]
async with engine.begin() as conn:
for sql in fixups:
try:
await conn.execute(text(sql))
except Exception: # noqa: BLE001
pass # already applied, or table doesn't exist yet
@asynccontextmanager
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
"""Application startup / shutdown lifecycle."""
@@ -44,10 +63,10 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
try:
engine = get_engine()
await create_all_tables(engine)
await _apply_schema_fixups(engine)
_logger.info("db_tables_ready")
except Exception as e: # noqa: BLE001
_logger.error("db_tables_create_failed", error=str(e))
# Continue anyway — /api/health will reflect the broken state
sm = get_sessionmaker()
try:
@@ -56,7 +75,9 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await seed_default_settings(session)
# 2) ensure admin setup token
token = await get_admin_setup_token(session)
# 3) check if any admin exists
# 3) check if any admin exists — ALWAYS print the setup URL
# (user requested: even if admin exists, show the token for
# reference / debugging)
from sqlalchemy import func
admins_count = (
@@ -65,16 +86,18 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
)
).scalar_one()
if admins_count == 0:
_logger.warning(
"no_admin_yet",
setup_url=f"/register/admin?token={token}",
)
print(f"\n=== AI-RPG Admin Setup ===")
print(f"No admin user yet. Open this URL in your browser:")
print(f" /register/admin?token={token}")
print(f"===========================\n")
_logger.warning("no_admin_yet", setup_url=f"/register/admin?token={token}")
print(f"\n{'=' * 60}")
print(f" AI-RPG — No admin user yet.")
print(f" Open this URL to create the first admin:")
print(f" http://localhost:8080/register/admin?token={token}")
print(f"{'=' * 60}\n")
else:
_logger.info("admins_present", count=admins_count)
# Still print the token URL for reference
print(f"\n Admin already exists. Admin register URL (for reference):")
print(f" /register/admin?token={token}")
print(f" (This URL is blocked since an admin already exists.)\n")
# 4) seed builtin presets (idempotent)
from app.migrations.seed import seed_builtin_presets
@@ -94,7 +117,6 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]:
_logger.warning("qdrant_init_failed", error=str(e))
except Exception as e: # noqa: BLE001
_logger.error("startup_failed", error=str(e))
# Don't crash — let /api/health reflect the broken state
pass
yield

View File

@@ -245,7 +245,12 @@ BUILTIN_PRESETS = [FANTASY_PRESET, SCI_FI_PRESET]
async def seed_builtin_presets(session: AsyncSession) -> None:
"""Insert builtin presets if they don't yet exist. Owned by the first admin (or a system sentinel)."""
"""Insert builtin presets if they don't yet exist.
Builtin presets have owner_id=NULL (they are system presets, not owned by
any specific user). This avoids the FK violation that occurred on first
startup when no admin user existed yet.
"""
for preset_data in BUILTIN_PRESETS:
existing = (
await session.execute(
@@ -254,18 +259,8 @@ async def seed_builtin_presets(session: AsyncSession) -> None:
).scalar_one_or_none()
if existing is not None:
continue
# Find any admin to own the preset, or use a sentinel UUID
from app.models import User
from sqlalchemy import func
admin = (
await session.execute(
select(User).where(User.is_admin.is_(True)).limit(1)
)
).scalar_one_or_none()
owner_id = admin.id if admin else uuid.UUID("00000000-0000-0000-0000-000000000001")
preset = WorldPreset(
owner_id=owner_id,
owner_id=None, # system preset — no owner
**preset_data,
version=1,
)

View File

@@ -91,8 +91,11 @@ class WorldPreset(Base):
id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
owner_id: Mapped[uuid.UUID] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False
# owner_id is nullable so that builtin/system presets can exist without
# a real user owning them. Custom presets created via the admin UI get
# the admin's user_id.
owner_id: Mapped[uuid.UUID | None] = mapped_column(
PG_UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
name: Mapped[str] = mapped_column(String(255), nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
@@ -112,7 +115,7 @@ class WorldPreset(Base):
DateTime(timezone=True), nullable=False, default=_now, onupdate=_now
)
owner: Mapped[User] = relationship(back_populates="presets")
owner: Mapped[User | None] = relationship(back_populates="presets")
worlds: Mapped[list["World"]] = relationship(back_populates="preset")