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())