"""Admin operations routes.""" from __future__ import annotations import time from datetime import datetime, timezone from decimal import Decimal from typing import Annotated, Literal from fastapi import APIRouter, Depends, HTTPException, Query, status from pydantic import BaseModel, Field from ..auth_sessions import ( active_session_count, deactivate_managed_user, list_managed_users, update_managed_user, upsert_managed_user, ) from ..config import settings from ..db import acquire, get_pool, healthcheck from ..deps import Principal, Role, require_role from ..engine_client import engine_client from ..runtime_policy import require_runtime_fallback_allowed from ..services.voice import voice_service from ..services import rag from ..store import store router = APIRouter(prefix="/admin", tags=["admin"]) AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))] HealthStatus = Literal["ok", "degraded", "down"] UsageBudgetStatus = Literal["disabled", "ok", "warn", "exceeded"] class AdminServiceHealth(BaseModel): key: str name: str status: HealthStatus detail: str metric: str load: float class AdminHealthResponse(BaseModel): status: HealthStatus environment: str engine_mode: str services: list[AdminServiceHealth] class AdminUsageBreakdown(BaseModel): provider: str model: str turns: int tokens_in: int tokens_out: int cost_usd: float class AdminUsageBudget(BaseModel): limit_usd: float used_ratio: float remaining_usd: float | None status: UsageBudgetStatus class AdminUsageResponse(BaseModel): source: Literal["database", "server_session_registry"] durable: bool window_days: int generated_at: float total_turns: int metered_turns: int tokens_in: int tokens_out: int cost_usd: float budget: AdminUsageBudget by_provider: list[AdminUsageBreakdown] class AdminEngineConfigResponse(BaseModel): engine_mode: str engine_url: str model: str updated_by: str | None = None updated_at: float | None = None durable: bool = False source: Literal["database", "runtime_cache", "runtime_default"] = "runtime_default" class AdminEngineConfigPatch(BaseModel): engine_mode: str | None = None engine_url: str | None = None model: str | None = None RoleName = Literal["learner", "teacher", "admin"] class AdminUserResponse(BaseModel): user_id: str email: str display_name: str role: RoleName cohort_ids: list[str] affiliation: str active_sessions: int created_at: float last_seen_at: float source: Literal["database", "server_session_registry"] class AdminUsersResponse(BaseModel): source: Literal["database", "server_session_registry"] durable: bool users: list[AdminUserResponse] class AdminUserPatch(BaseModel): display_name: str | None = Field(default=None, min_length=1, max_length=80) role: RoleName | None = None affiliation: str | None = Field(default=None, max_length=120) cohort_ids: list[str] | None = None class RuntimeHealthMetrics(BaseModel): engine_latency_ms: float | None = None db_pool_size: int = 0 db_pool_idle: int = 0 db_pool_max: int = 0 active_users: int = 0 active_auth_sessions: int = 0 active_sessions: int = 0 ended_sessions: int = 0 pending_reviews: int = 0 def _clamp01(value: float) -> float: return round(max(0.0, min(1.0, value)), 3) def _pool_load(metrics: RuntimeHealthMetrics) -> float: if metrics.db_pool_max <= 0: return 0.0 busy = max(0, metrics.db_pool_size - metrics.db_pool_idle) return _clamp01(busy / metrics.db_pool_max) def _workload_load(count: int, expected_capacity: int) -> float: if expected_capacity <= 0: return 0.0 return _clamp01(count / expected_capacity) def _decimal_to_float(value: object) -> float: if value is None: return 0.0 if isinstance(value, Decimal): return float(value) try: return float(value) except (TypeError, ValueError): return 0.0 def _safe_usage_int(value: object) -> int: try: return int(value or 0) except (TypeError, ValueError): return 0 def _usage_budget(cost_usd: float) -> AdminUsageBudget: limit = max(0.0, float(settings.admin_usage_budget_usd or 0.0)) if limit <= 0: return AdminUsageBudget( limit_usd=0.0, used_ratio=0.0, remaining_usd=None, status="disabled", ) used_ratio = max(0.0, cost_usd / limit) status_value: UsageBudgetStatus = "ok" if used_ratio >= 1.0: status_value = "exceeded" elif used_ratio >= 0.8: status_value = "warn" return AdminUsageBudget( limit_usd=round(limit, 6), used_ratio=round(used_ratio, 4), remaining_usd=round(max(0.0, limit - cost_usd), 6), status=status_value, ) async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics: metrics = RuntimeHealthMetrics() try: pool = get_pool() metrics.db_pool_size = int(pool.get_size()) metrics.db_pool_idle = int(pool.get_idle_size()) metrics.db_pool_max = int(pool.get_max_size()) except Exception: pass if not db_ok: return metrics try: async with acquire(role="admin") as conn: row = await conn.fetchrow( """ SELECT (SELECT COUNT(*) FROM app.app_user WHERE is_active) AS active_users, ( SELECT COUNT(*) FROM app.auth_session WHERE revoked_at IS NULL AND expires_at > now() ) AS active_auth_sessions, ( SELECT COUNT(*) FROM app.sessions WHERE ended_at IS NULL ) AS active_sessions, ( SELECT COUNT(*) FROM app.sessions WHERE ended_at IS NOT NULL ) AS ended_sessions, ( SELECT COUNT(*) FROM app.sessions s LEFT JOIN app.session_summary ss ON ss.session_id = s.id WHERE s.ended_at IS NOT NULL AND ss.session_id IS NULL ) AS pending_reviews """ ) if row is not None: metrics.active_users = int(row["active_users"] or 0) metrics.active_auth_sessions = int(row["active_auth_sessions"] or 0) metrics.active_sessions = int(row["active_sessions"] or 0) metrics.ended_sessions = int(row["ended_sessions"] or 0) metrics.pending_reviews = int(row["pending_reviews"] or 0) except Exception: return metrics return metrics async def _usage_from_database(window_days: int) -> AdminUsageResponse: async with acquire(role="admin") as conn: total_row = await conn.fetchrow( """ SELECT COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns, COUNT(*) FILTER ( WHERE speaker = 'client' AND ( llm_provider IS NOT NULL OR model IS NOT NULL OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL OR cost_usd IS NOT NULL ) ) AS metered_turns, COALESCE(SUM(tokens_in) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_in, COALESCE(SUM(tokens_out) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_out, COALESCE(SUM(cost_usd) FILTER (WHERE speaker = 'client'), 0)::numeric AS cost_usd FROM app.turns WHERE created_at >= now() - ($1::int * interval '1 day') """, window_days, ) rows = await conn.fetch( """ SELECT COALESCE(llm_provider, 'unknown') AS provider, COALESCE(model, 'unknown') AS model, COUNT(*) AS turns, COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in, COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out, COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd FROM app.turns WHERE created_at >= now() - ($1::int * interval '1 day') AND speaker = 'client' AND ( llm_provider IS NOT NULL OR model IS NOT NULL OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL OR cost_usd IS NOT NULL ) GROUP BY 1, 2 ORDER BY cost_usd DESC, tokens_in + tokens_out DESC, turns DESC LIMIT 12 """, window_days, ) total_cost = round(_decimal_to_float(total_row["cost_usd"] if total_row else 0), 6) return AdminUsageResponse( source="database", durable=True, window_days=window_days, generated_at=time.time(), total_turns=_safe_usage_int(total_row["total_turns"] if total_row else 0), metered_turns=_safe_usage_int(total_row["metered_turns"] if total_row else 0), tokens_in=_safe_usage_int(total_row["tokens_in"] if total_row else 0), tokens_out=_safe_usage_int(total_row["tokens_out"] if total_row else 0), cost_usd=total_cost, budget=_usage_budget(total_cost), by_provider=[ AdminUsageBreakdown( provider=str(row["provider"] or "unknown"), model=str(row["model"] or "unknown"), turns=_safe_usage_int(row["turns"]), tokens_in=_safe_usage_int(row["tokens_in"]), tokens_out=_safe_usage_int(row["tokens_out"]), cost_usd=round(_decimal_to_float(row["cost_usd"]), 6), ) for row in rows ], ) def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse: window_start = time.time() - (window_days * 86400) total_turns = 0 metered_turns = 0 tokens_in = 0 tokens_out = 0 cost_usd = 0.0 buckets: dict[tuple[str, str], dict[str, int | float]] = {} for sess in store.list(): for turn in getattr(sess, "turns", []) or []: if getattr(turn, "speaker", "") != "client": continue if float(getattr(turn, "created_at", 0.0) or 0.0) < window_start: continue total_turns += 1 provider = str(getattr(turn, "llm_provider", None) or "unknown") model = str(getattr(turn, "model", None) or "unknown") turn_tokens_in = _safe_usage_int(getattr(turn, "tokens_in", 0)) turn_tokens_out = _safe_usage_int(getattr(turn, "tokens_out", 0)) turn_cost = _decimal_to_float(getattr(turn, "cost_usd", 0.0)) is_metered = ( provider != "unknown" or model != "unknown" or turn_tokens_in > 0 or turn_tokens_out > 0 or turn_cost > 0 ) if not is_metered: continue metered_turns += 1 tokens_in += turn_tokens_in tokens_out += turn_tokens_out cost_usd += turn_cost key = (provider, model) bucket = buckets.setdefault( key, {"turns": 0, "tokens_in": 0, "tokens_out": 0, "cost_usd": 0.0}, ) bucket["turns"] = int(bucket["turns"]) + 1 bucket["tokens_in"] = int(bucket["tokens_in"]) + turn_tokens_in bucket["tokens_out"] = int(bucket["tokens_out"]) + turn_tokens_out bucket["cost_usd"] = float(bucket["cost_usd"]) + turn_cost by_provider = [ AdminUsageBreakdown( provider=provider, model=model, turns=int(values["turns"]), tokens_in=int(values["tokens_in"]), tokens_out=int(values["tokens_out"]), cost_usd=round(float(values["cost_usd"]), 6), ) for (provider, model), values in sorted( buckets.items(), key=lambda item: ( -float(item[1]["cost_usd"]), -(int(item[1]["tokens_in"]) + int(item[1]["tokens_out"])), -int(item[1]["turns"]), ), )[:12] ] total_cost = round(cost_usd, 6) return AdminUsageResponse( source="server_session_registry", durable=False, window_days=window_days, generated_at=time.time(), total_turns=total_turns, metered_turns=metered_turns, tokens_in=tokens_in, tokens_out=tokens_out, cost_usd=total_cost, budget=_usage_budget(total_cost), by_provider=by_provider, ) class AdminUserCreate(BaseModel): email: str = Field(..., min_length=3, max_length=254) display_name: str = Field(..., min_length=1, max_length=80) role: RoleName = "learner" affiliation: str | None = Field(default=None, max_length=120) cohort_ids: list[str] = Field(default_factory=list) class AdminUserDeleteResponse(BaseModel): ok: bool user_id: str _ENGINE_CONFIG: AdminEngineConfigResponse | None = None ENGINE_MODES = {"claude_api", "claude_cli", "openai", "solar"} ENGINE_MODE_ALIASES = {"messages_api": "claude_api"} def _normalize_email(value: str) -> str: email = value.strip().lower() if "@" not in email: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") local, domain = email.rsplit("@", 1) if not local or not domain: raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="email is invalid") allowed = {item.strip().lower().lstrip("@") for item in settings.auth_allowed_email_domains if item.strip()} if domain not in allowed: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed") return email def _default_engine_config() -> AdminEngineConfigResponse: return AdminEngineConfigResponse( engine_mode=settings.engine_mode, engine_url=settings.engine_url, model="gateway-default", durable=False, source="runtime_default", ) def _normalize_engine_mode(value: str) -> str: mode = ENGINE_MODE_ALIASES.get(value.strip(), value.strip()) if mode not in ENGINE_MODES: raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"unsupported engine mode {value}", ) return mode def _normalize_engine_url(value: str) -> str: url = value.strip().rstrip("/") if not (url.startswith("http://") or url.startswith("https://")): raise HTTPException( status.HTTP_422_UNPROCESSABLE_ENTITY, detail="engine_url must start with http:// or https://", ) return url def _updated_at_ts(value: datetime | None) -> float | None: if value is None: return None if value.tzinfo is None: value = value.replace(tzinfo=timezone.utc) return value.timestamp() def _engine_config_from_row(row) -> AdminEngineConfigResponse: return AdminEngineConfigResponse( engine_mode=_normalize_engine_mode(row["engine_mode"]), engine_url=_normalize_engine_url(row["engine_url"]), model=row["model"], updated_by=row["updated_by"], updated_at=_updated_at_ts(row["updated_at"]), durable=True, source="database", ) async def _current_engine_config() -> AdminEngineConfigResponse: if _ENGINE_CONFIG is not None: return _ENGINE_CONFIG try: pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ SELECT engine_mode, engine_url, model, updated_by, updated_at FROM app.admin_engine_config WHERE id = TRUE """ ) if row is not None: return _engine_config_from_row(row) except Exception: require_runtime_fallback_allowed("admin engine config") require_runtime_fallback_allowed("admin engine config") return _default_engine_config() async def apply_engine_config_from_store() -> AdminEngineConfigResponse: """Load admin engine settings and apply them to the live engine client.""" config = await _current_engine_config() await engine_client.configure( base_url=config.engine_url, engine_mode=config.engine_mode, default_model=config.model, ) return config def _overall_status(services: list[AdminServiceHealth]) -> HealthStatus: if any(s.status == "down" for s in services): return "down" if any(s.status == "degraded" for s in services): return "degraded" return "ok" def _engine_unavailable_detail(detail: str) -> str: if detail.lstrip().startswith("{") and '"ok":false' in detail: return "Engine readiness failed" return detail async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse: return AdminUserResponse( user_id=user.user_id, email=user.email, display_name=user.display_name, role=user.role, cohort_ids=user.cohort_ids, affiliation=user.affiliation, active_sessions=await active_session_count(user.user_id), created_at=user.created_at, last_seen_at=user.last_seen_at, source="database" if durable else "server_session_registry", ) @router.get("/health", response_model=AdminHealthResponse) async def admin_health(principal: AdminPrincipal) -> AdminHealthResponse: """Return operational health from live backend checks.""" current_engine = await _current_engine_config() db_ok = await healthcheck() engine_started = time.perf_counter() engine = await engine_client.health_detail() engine_ok = bool(engine.get("ok")) engine_detail = _engine_unavailable_detail( str(engine.get("detail") or "engine readiness unavailable") ) engine_latency_ms = (time.perf_counter() - engine_started) * 1000 voice_ok = voice_service.is_available() metrics = await _runtime_health_metrics(db_ok=db_ok) metrics.engine_latency_ms = engine_latency_ms if engine_ok else None pool_load = _pool_load(metrics) session_load = _workload_load(metrics.active_sessions, 50) review_load = _workload_load(metrics.pending_reviews, 50) runtime_fallback_is_enabled = settings.environment == "dev" db_status: HealthStatus = "ok" if db_ok else ("degraded" if runtime_fallback_is_enabled else "down") db_detail = ( "사용자, 세션, 리뷰 저장" if db_ok else ( "DB 연결 전까지 비영구 개발 런타임 기록 사용" if runtime_fallback_is_enabled else "DB 저장소에 연결할 수 없습니다" ) ) db_metric = ( f"풀 {max(0, metrics.db_pool_size - metrics.db_pool_idle)}/{metrics.db_pool_max}" if db_ok else ("비영구 런타임 기록" if runtime_fallback_is_enabled else "저장소 중단") ) services = [ AdminServiceHealth( key="engine", name="응답 생성", status="ok" if engine_ok else "down", detail="AI 엔진 생성 준비 완료" if engine_ok else engine_detail, metric=f"{engine_latency_ms:.0f}ms" if engine_ok else "로그인/설정 필요", load=_clamp01(engine_latency_ms / 1500) if engine_ok else 0.0, ), AdminServiceHealth( key="db", name="영구 저장소", status=db_status, detail=db_detail, metric=db_metric, load=max(pool_load, session_load) if db_ok else 0.0, ), AdminServiceHealth( key="voice", name="음성 입력", status="ok" if voice_ok else "degraded", detail="음성 입력과 재생", metric="OpenAI 연결" if voice_ok else "설정 필요", load=0.05 if voice_ok else 0.0, ), AdminServiceHealth( key="evaluation", name="리뷰 생성", status="ok" if engine_ok else "degraded", detail="회기 종료 후 피드백 생성", metric=f"대기 {metrics.pending_reviews}건", load=review_load if engine_ok else 0.0, ), AdminServiceHealth( key="kb", name="지식 검색", status="ok" if db_ok else "degraded", detail=f"검색 기준값 {rag.CRAG_TOP1_THRESHOLD}", metric=( f"활성 세션 {metrics.active_sessions}건" if db_ok else "대기 중" ), load=max(pool_load, session_load) if db_ok else 0.0, ), ] return AdminHealthResponse( status=_overall_status(services), environment=settings.environment, engine_mode=current_engine.engine_mode, services=services, ) @router.get("/usage", response_model=AdminUsageResponse) async def admin_usage( principal: AdminPrincipal, window_days: Annotated[int, Query(ge=1, le=90)] = 7, ) -> AdminUsageResponse: """Return AI token/cost usage from persisted turns or dev fallback state.""" try: return await _usage_from_database(window_days) except Exception: require_runtime_fallback_allowed("admin usage") return _usage_from_runtime_store(window_days) @router.get("/engine-config", response_model=AdminEngineConfigResponse) async def get_engine_config(principal: AdminPrincipal) -> AdminEngineConfigResponse: """Return the current admin-managed engine settings.""" return await _current_engine_config() @router.patch("/engine-config", response_model=AdminEngineConfigResponse) async def patch_engine_config( body: AdminEngineConfigPatch, principal: AdminPrincipal, ) -> AdminEngineConfigResponse: """Persist engine settings for administrators.""" global _ENGINE_CONFIG current = await _current_engine_config() next_mode = _normalize_engine_mode(body.engine_mode or current.engine_mode) next_url = _normalize_engine_url(body.engine_url or current.engine_url) next_config = AdminEngineConfigResponse( engine_mode=next_mode, engine_url=next_url, model=(body.model or current.model).strip(), updated_by=principal.email, updated_at=time.time(), durable=False, source="runtime_cache", ) try: pool = get_pool() async with pool.acquire() as conn: row = await conn.fetchrow( """ INSERT INTO app.admin_engine_config ( id, engine_mode, engine_url, model, updated_by, updated_at ) VALUES (TRUE, $1, $2, $3, $4, now()) ON CONFLICT (id) DO UPDATE SET engine_mode = EXCLUDED.engine_mode, engine_url = EXCLUDED.engine_url, model = EXCLUDED.model, updated_by = EXCLUDED.updated_by, updated_at = now() RETURNING engine_mode, engine_url, model, updated_by, updated_at """, next_config.engine_mode, next_config.engine_url, next_config.model, principal.email, ) next_config = _engine_config_from_row(row) except Exception as exc: if settings.environment != "dev": raise HTTPException( status.HTTP_503_SERVICE_UNAVAILABLE, detail="engine config persistence unavailable", ) from exc _ENGINE_CONFIG = next_config await engine_client.configure( base_url=next_config.engine_url, engine_mode=next_config.engine_mode, default_model=next_config.model, ) return next_config @router.get("/users", response_model=AdminUsersResponse) async def list_users(principal: AdminPrincipal) -> AdminUsersResponse: """Return users observed by the server-side auth/session boundary.""" users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user list") return AdminUsersResponse( source="database" if durable else "server_session_registry", durable=durable, users=[await _admin_user_response(user, durable=durable) for user in users], ) @router.post("/users", response_model=AdminUserResponse, status_code=status.HTTP_201_CREATED) async def create_user( body: AdminUserCreate, principal: AdminPrincipal, ) -> AdminUserResponse: """Create or reactivate a managed user without requiring that user to log in first.""" user = await upsert_managed_user( email=_normalize_email(body.email), display_name=body.display_name, role=body.role, affiliation=body.affiliation, cohort_ids=body.cohort_ids, reactivate=True, ) users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user create") return await _admin_user_response(user, durable=durable) @router.patch("/users/{user_id}", response_model=AdminUserResponse) async def patch_user( user_id: str, body: AdminUserPatch, principal: AdminPrincipal, ) -> AdminUserResponse: """Update a server-known user's role/profile for the current API process.""" next_user = await update_managed_user( user_id, display_name=body.display_name, role=body.role, affiliation=body.affiliation, cohort_ids=body.cohort_ids, ) if next_user is None: raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") users, durable = await list_managed_users() if not durable: require_runtime_fallback_allowed("admin user update") return await _admin_user_response(next_user, durable=durable) @router.delete("/users/{user_id}", response_model=AdminUserDeleteResponse) async def delete_user( user_id: str, principal: AdminPrincipal, ) -> AdminUserDeleteResponse: """Deactivate a managed user and revoke any active browser sessions.""" if user_id == principal.user_id: raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="cannot deactivate current admin") if not await deactivate_managed_user(user_id): raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found") return AdminUserDeleteResponse(ok=True, user_id=user_id)