Stabilize runtime auth and E2E coverage
This commit is contained in:
parent
6a3e3b541c
commit
188e899394
133 changed files with 55987 additions and 6775 deletions
539
apps/api/app/routes/admin.py
Normal file
539
apps/api/app/routes/admin.py
Normal file
|
|
@ -0,0 +1,539 @@
|
|||
"""Admin operations routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, 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
|
||||
|
||||
router = APIRouter(prefix="/admin", tags=["admin"])
|
||||
|
||||
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
|
||||
HealthStatus = Literal["ok", "degraded", "down"]
|
||||
|
||||
|
||||
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 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)
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
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("/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)
|
||||
|
|
@ -1,84 +1,452 @@
|
|||
"""인증 라우트 — BFF OAuth 2.1 Auth Code + PKCE(S256) 스텁.
|
||||
"""BFF authentication routes.
|
||||
|
||||
마스터플랜 §5: BFF + OAuth 2.1, 토큰은 서버(Redis)에만, 브라우저엔 __Host- HttpOnly 쿠키.
|
||||
미성년 사례데이터 + 상담 민감정보 -> XSS 토큰탈취 원천 차단.
|
||||
1차 = Google OIDC 단독, 한신대 SSO 는 2차(R11, Authlib provider 추상화 뒤).
|
||||
|
||||
이 파일은 라우트 시그니처 + 흐름 + TODO. 실제 OAuth 교환/Redis 세션은 Phase 2 트랙 B.
|
||||
The production path is Google OIDC authorization code + PKCE. Until the DB
|
||||
session table is wired, the issued browser sessions are server-side in-proc
|
||||
sessions backed by an opaque HttpOnly cookie. Local development also has a
|
||||
dev-only server login endpoint so Playwright can exercise auth without trusting
|
||||
browser localStorage.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated, Optional
|
||||
import base64
|
||||
import hashlib
|
||||
import secrets
|
||||
import time
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated, Literal, Optional
|
||||
from urllib.parse import urlencode, urlsplit
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query, Response, status
|
||||
import httpx
|
||||
from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
|
||||
from ..config import settings
|
||||
from ..deps import CurrentPrincipal
|
||||
from ..deps import CurrentPrincipal, Principal, Role
|
||||
|
||||
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||
|
||||
GOOGLE_AUTHORIZE_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
||||
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
||||
GOOGLE_TOKENINFO_URL = "https://oauth2.googleapis.com/tokeninfo"
|
||||
OAUTH_STATE_TTL_SECONDS = 10 * 60
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class OAuthState:
|
||||
code_verifier: str
|
||||
next_path: str
|
||||
created_at: float
|
||||
|
||||
|
||||
_oauth_states: dict[str, OAuthState] = {}
|
||||
|
||||
|
||||
class MeResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
|
||||
|
||||
class AuthConfigResponse(BaseModel):
|
||||
google_oauth_configured: bool
|
||||
allowed_email_domains: list[str]
|
||||
redirect_uri: str
|
||||
dev_login_enabled: bool
|
||||
|
||||
|
||||
class DevLoginRequest(BaseModel):
|
||||
email: str
|
||||
role: Literal["learner", "teacher", "admin"] = "learner"
|
||||
display_name: str | None = None
|
||||
|
||||
|
||||
def _normalize_domain(domain: str | None) -> str:
|
||||
return (domain or "").strip().lower().lstrip("@")
|
||||
|
||||
|
||||
def _normalize_email(email: str | None) -> str:
|
||||
return (email or "").strip().lower()
|
||||
|
||||
|
||||
def _email_domain(email: str | None) -> str:
|
||||
value = _normalize_email(email)
|
||||
if "@" not in value:
|
||||
return ""
|
||||
return value.rsplit("@", 1)[1]
|
||||
|
||||
|
||||
def _normalize_email_set(values: list[str]) -> set[str]:
|
||||
return {email for value in values if (email := _normalize_email(value))}
|
||||
|
||||
|
||||
def allowed_email_domains() -> set[str]:
|
||||
"""Configured login email domains, normalized for claim checks."""
|
||||
return {
|
||||
normalized
|
||||
for domain in settings.auth_allowed_email_domains
|
||||
if (normalized := _normalize_domain(domain))
|
||||
}
|
||||
|
||||
|
||||
def validate_google_identity_domain(
|
||||
*,
|
||||
email: str | None,
|
||||
email_verified: bool,
|
||||
hosted_domain: str | None = None,
|
||||
) -> str:
|
||||
"""Reject Google identities outside the allowed email domain list.
|
||||
|
||||
Google Console authorized domains protect app/redirect domains, not user
|
||||
email domains. After id_token signature/audience/issuer validation, call
|
||||
this check with the `email`, `email_verified`, and optional `hd` claims.
|
||||
"""
|
||||
normalized_email = _normalize_email(email)
|
||||
domain = _email_domain(normalized_email)
|
||||
allowed = allowed_email_domains()
|
||||
|
||||
if not allowed:
|
||||
raise HTTPException(
|
||||
status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail="allowed email domains are not configured",
|
||||
)
|
||||
if not normalized_email or not domain:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email claim is required")
|
||||
if not email_verified:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email is not verified")
|
||||
if domain not in allowed:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email domain is not allowed")
|
||||
|
||||
hd = _normalize_domain(hosted_domain)
|
||||
if hd and hd not in allowed:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="hosted domain is not allowed")
|
||||
|
||||
return normalized_email
|
||||
|
||||
|
||||
def _role_for_email(email: str) -> Role:
|
||||
normalized = _normalize_email(email)
|
||||
if normalized in _normalize_email_set(settings.auth_admin_emails):
|
||||
return Role.ADMIN
|
||||
if normalized in _normalize_email_set(settings.auth_teacher_emails):
|
||||
return Role.TEACHER
|
||||
return Role.LEARNER
|
||||
|
||||
|
||||
def _safe_next_path(next_path: str | None) -> str:
|
||||
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
|
||||
return "/"
|
||||
return next_path
|
||||
|
||||
|
||||
def _url_origin(value: str | None) -> str | None:
|
||||
if not value:
|
||||
return None
|
||||
parsed = urlsplit(value)
|
||||
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
||||
return None
|
||||
return f"{parsed.scheme}://{parsed.netloc}".rstrip("/")
|
||||
|
||||
|
||||
def _is_local_origin(origin: str) -> bool:
|
||||
host = urlsplit(origin).hostname or ""
|
||||
return host in {"localhost", "127.0.0.1", "::1"}
|
||||
|
||||
|
||||
def _configured_frontend_origins() -> list[str]:
|
||||
origins: list[str] = []
|
||||
for value in [settings.frontend_base_url, *settings.cors_origins]:
|
||||
origin = _url_origin(value)
|
||||
if origin and origin not in origins:
|
||||
origins.append(origin)
|
||||
return origins
|
||||
|
||||
|
||||
def _frontend_origin_for_request(request: Request | None = None) -> str:
|
||||
origins = _configured_frontend_origins()
|
||||
fallback = (_url_origin(settings.frontend_base_url) or "http://localhost:5173").rstrip("/")
|
||||
|
||||
if request is not None:
|
||||
for header_name in ("origin", "referer"):
|
||||
candidate = _url_origin(request.headers.get(header_name))
|
||||
if candidate in origins:
|
||||
return candidate
|
||||
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
||||
hostname = host.rsplit(":", 1)[0].lower() if host else ""
|
||||
if hostname == "api-vignette.chanpaca.net":
|
||||
return "https://vignette.chanpaca.net"
|
||||
if hostname in {"localhost", "127.0.0.1", "::1"}:
|
||||
return fallback
|
||||
|
||||
if not _is_local_origin(fallback):
|
||||
return fallback
|
||||
|
||||
for origin in origins:
|
||||
hostname = (urlsplit(origin).hostname or "").lower()
|
||||
if not _is_local_origin(origin) and hostname != "api-vignette.chanpaca.net":
|
||||
return origin
|
||||
return fallback
|
||||
|
||||
|
||||
def _frontend_url(path: str, request: Request | None = None) -> str:
|
||||
base = _frontend_origin_for_request(request)
|
||||
return f"{base}{_safe_next_path(path)}"
|
||||
|
||||
|
||||
def _pkce_challenge(verifier: str) -> str:
|
||||
digest = hashlib.sha256(verifier.encode("ascii")).digest()
|
||||
return base64.urlsafe_b64encode(digest).decode("ascii").rstrip("=")
|
||||
|
||||
|
||||
def _prune_oauth_states() -> None:
|
||||
cutoff = time.time() - OAUTH_STATE_TTL_SECONDS
|
||||
stale = [key for key, value in _oauth_states.items() if value.created_at < cutoff]
|
||||
for key in stale:
|
||||
_oauth_states.pop(key, None)
|
||||
|
||||
|
||||
def _cookie_secure() -> bool:
|
||||
# The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome
|
||||
# accepts Secure cookies on localhost, which keeps dev and prod semantics
|
||||
# aligned.
|
||||
return settings.is_prod or settings.cookie_name.startswith("__Host-")
|
||||
|
||||
|
||||
def _set_session_cookie(response: Response, sid: str) -> None:
|
||||
response.set_cookie(
|
||||
key=settings.cookie_name,
|
||||
value=sid,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=_cookie_secure(),
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
if settings.environment == "dev":
|
||||
response.set_cookie(
|
||||
key="vignette_sid",
|
||||
value=sid,
|
||||
max_age=settings.session_ttl_seconds,
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _delete_session_cookie(response: Response) -> None:
|
||||
response.delete_cookie(
|
||||
settings.cookie_name,
|
||||
httponly=True,
|
||||
secure=_cookie_secure(),
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
if settings.environment == "dev":
|
||||
response.delete_cookie(
|
||||
"vignette_sid",
|
||||
httponly=True,
|
||||
secure=False,
|
||||
samesite="lax",
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
def _me_response(user: SessionUser | Principal) -> MeResponse:
|
||||
return MeResponse(
|
||||
user_id=user.user_id,
|
||||
email=getattr(user, "email", ""),
|
||||
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
|
||||
role=user.role.value if isinstance(user.role, Role) else user.role,
|
||||
cohort_ids=user.cohort_ids,
|
||||
)
|
||||
|
||||
|
||||
def _frontend_login_redirect(reason: str, request: Request) -> RedirectResponse:
|
||||
base_url = _frontend_origin_for_request(request)
|
||||
return RedirectResponse(f"{base_url}/login?{urlencode({'oauth': reason})}", status_code=302)
|
||||
|
||||
|
||||
def _dev_login_available(request: Request) -> bool:
|
||||
if settings.environment != "dev" or not settings.auth_dev_login_enabled:
|
||||
return False
|
||||
|
||||
for header_name in ("origin", "referer"):
|
||||
origin = _url_origin(request.headers.get(header_name))
|
||||
if origin and not _is_local_origin(origin):
|
||||
return False
|
||||
|
||||
forwarded_host = request.headers.get("x-forwarded-host")
|
||||
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
||||
origin = _url_origin(f"http://{host}") if host else None
|
||||
return bool(origin and _is_local_origin(origin))
|
||||
|
||||
|
||||
@router.get("/config", response_model=AuthConfigResponse)
|
||||
async def auth_config(request: Request) -> AuthConfigResponse:
|
||||
"""Return non-secret login configuration for the browser login screen."""
|
||||
return AuthConfigResponse(
|
||||
google_oauth_configured=bool(
|
||||
settings.oauth_google_client_id and settings.oauth_google_client_secret
|
||||
),
|
||||
allowed_email_domains=sorted(allowed_email_domains()),
|
||||
redirect_uri=settings.oauth_redirect_uri,
|
||||
dev_login_enabled=_dev_login_available(request),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/login")
|
||||
async def login(
|
||||
request: Request,
|
||||
provider: Annotated[str, Query()] = "google",
|
||||
next: Annotated[str | None, Query()] = None,
|
||||
) -> RedirectResponse:
|
||||
"""OAuth Auth Code + PKCE 시작 (BFF).
|
||||
|
||||
절차:
|
||||
1. code_verifier 생성 -> S256 code_challenge
|
||||
2. state(CSRF) + verifier 를 서버 세션(Redis)에 저장
|
||||
3. provider authorize URL 로 302 (Google OIDC 1차)
|
||||
TODO: Authlib provider 추상화 + Redis state 저장. 현재 스텁 501.
|
||||
"""
|
||||
"""Start Google OIDC authorization code + PKCE login."""
|
||||
if provider != "google":
|
||||
# 한신대 SSO 는 2차 (R11)
|
||||
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail=f"provider {provider} not yet supported")
|
||||
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth login TODO (Phase 2 트랙 B)")
|
||||
return _frontend_login_redirect("unsupported_provider", request)
|
||||
if not settings.oauth_google_client_id or not settings.oauth_google_client_secret:
|
||||
return _frontend_login_redirect("not_configured", request)
|
||||
|
||||
_prune_oauth_states()
|
||||
state = secrets.token_urlsafe(32)
|
||||
verifier = secrets.token_urlsafe(64)
|
||||
_oauth_states[state] = OAuthState(
|
||||
code_verifier=verifier,
|
||||
next_path=_safe_next_path(next),
|
||||
created_at=time.time(),
|
||||
)
|
||||
|
||||
params = {
|
||||
"client_id": settings.oauth_google_client_id,
|
||||
"redirect_uri": settings.oauth_redirect_uri,
|
||||
"response_type": "code",
|
||||
"scope": "openid email profile",
|
||||
"state": state,
|
||||
"code_challenge": _pkce_challenge(verifier),
|
||||
"code_challenge_method": "S256",
|
||||
"prompt": "select_account",
|
||||
}
|
||||
return RedirectResponse(f"{GOOGLE_AUTHORIZE_URL}?{urlencode(params)}", status_code=302)
|
||||
|
||||
|
||||
@router.get("/callback")
|
||||
async def callback(
|
||||
response: Response,
|
||||
request: Request,
|
||||
code: Annotated[Optional[str], Query()] = None,
|
||||
state: Annotated[Optional[str], Query()] = None,
|
||||
) -> RedirectResponse:
|
||||
"""OAuth 콜백 — code -> token 교환 후 서버 세션 발급.
|
||||
"""Exchange Google auth code, validate identity, and issue a BFF cookie."""
|
||||
if not code or not state:
|
||||
return _frontend_login_redirect("missing_callback", request)
|
||||
|
||||
절차:
|
||||
1. state 검증 (Redis 의 저장값과 대조, CSRF)
|
||||
2. code + code_verifier 로 token 교환 (PKCE)
|
||||
3. id_token 검증 -> user upsert -> role/cohort 매핑
|
||||
4. Redis 세션 생성 -> __Host- HttpOnly Secure SameSite=Lax 쿠키 set
|
||||
5. IRB 동의 미이행 시 동의 게이트로 리다이렉트 (마스터플랜 §7)
|
||||
TODO: 전체 교환 구현. 현재 스텁 501.
|
||||
_prune_oauth_states()
|
||||
stored = _oauth_states.pop(state, None)
|
||||
if stored is None:
|
||||
return _frontend_login_redirect("invalid_state", request)
|
||||
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
token_res = await client.post(
|
||||
GOOGLE_TOKEN_URL,
|
||||
data={
|
||||
"client_id": settings.oauth_google_client_id,
|
||||
"client_secret": settings.oauth_google_client_secret,
|
||||
"code": code,
|
||||
"grant_type": "authorization_code",
|
||||
"redirect_uri": settings.oauth_redirect_uri,
|
||||
"code_verifier": stored.code_verifier,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
)
|
||||
if token_res.status_code >= 400:
|
||||
return _frontend_login_redirect("token_exchange_failed", request)
|
||||
token_payload = token_res.json()
|
||||
id_token = token_payload.get("id_token")
|
||||
if not isinstance(id_token, str) or not id_token:
|
||||
return _frontend_login_redirect("id_token_missing", request)
|
||||
|
||||
info_res = await client.get(GOOGLE_TOKENINFO_URL, params={"id_token": id_token})
|
||||
if info_res.status_code >= 400:
|
||||
return _frontend_login_redirect("id_token_invalid", request)
|
||||
claims = info_res.json()
|
||||
|
||||
if claims.get("aud") != settings.oauth_google_client_id:
|
||||
return _frontend_login_redirect("audience_mismatch", request)
|
||||
issuer = claims.get("iss")
|
||||
if issuer not in {"accounts.google.com", "https://accounts.google.com"}:
|
||||
return _frontend_login_redirect("issuer_mismatch", request)
|
||||
|
||||
try:
|
||||
email = validate_google_identity_domain(
|
||||
email=claims.get("email"),
|
||||
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
|
||||
hosted_domain=claims.get("hd"),
|
||||
)
|
||||
except HTTPException:
|
||||
return _frontend_login_redirect("domain_not_allowed", request)
|
||||
role = _role_for_email(email)
|
||||
display_name = str(claims.get("name") or email)
|
||||
try:
|
||||
sid, _ = await create_session(
|
||||
email=email,
|
||||
display_name=display_name,
|
||||
role=role.value,
|
||||
cohort_ids=[],
|
||||
)
|
||||
except InactiveUserError as exc:
|
||||
return _frontend_login_redirect("inactive_user", request)
|
||||
|
||||
response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302)
|
||||
_set_session_cookie(response, sid)
|
||||
return response
|
||||
|
||||
|
||||
@router.post("/dev-login", response_model=MeResponse)
|
||||
async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse:
|
||||
"""Dev-only server login for local E2E and manual testing.
|
||||
|
||||
This is not a browser-side auth shortcut: the role is stored server-side and
|
||||
the browser only gets the same opaque HttpOnly cookie used by OAuth.
|
||||
"""
|
||||
raise HTTPException(status.HTTP_501_NOT_IMPLEMENTED, detail="OAuth callback TODO (Phase 2 트랙 B)")
|
||||
if not _dev_login_available(request):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="dev login is disabled")
|
||||
|
||||
email = validate_google_identity_domain(
|
||||
email=str(body.email),
|
||||
email_verified=True,
|
||||
hosted_domain=_email_domain(str(body.email)),
|
||||
)
|
||||
try:
|
||||
sid, user = await create_session(
|
||||
email=email,
|
||||
display_name=body.display_name or email,
|
||||
role=body.role,
|
||||
cohort_ids=[],
|
||||
)
|
||||
except InactiveUserError as exc:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="user is inactive") from exc
|
||||
_set_session_cookie(response, sid)
|
||||
return _me_response(user)
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(response: Response) -> dict[str, bool]:
|
||||
"""세션 무효화 (Redis 삭제 + 쿠키 만료). IRB 철회 즉시 무효화 경로 겸용.
|
||||
|
||||
TODO: Redis 세션 삭제. 현재 쿠키 만료만.
|
||||
"""
|
||||
response.delete_cookie(settings.cookie_name, httponly=True, secure=settings.is_prod, samesite="lax")
|
||||
async def logout(
|
||||
response: Response,
|
||||
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
|
||||
dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None,
|
||||
) -> dict[str, bool]:
|
||||
"""Revoke the current server session and expire the browser cookie."""
|
||||
await revoke_session(session_cookie or (dev_session_cookie if settings.environment == "dev" else None))
|
||||
_delete_session_cookie(response)
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(principal: CurrentPrincipal) -> MeResponse:
|
||||
"""현재 세션 주체 (프론트 부트스트랩용). 미인증이면 deps 에서 401."""
|
||||
return MeResponse(
|
||||
user_id=principal.user_id,
|
||||
role=principal.role.value,
|
||||
cohort_ids=principal.cohort_ids,
|
||||
)
|
||||
"""Return the current authenticated user. Unauthenticated requests are 401."""
|
||||
return _me_response(principal)
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ services/evaluator.py 의 2-loop 평가(fast/deep)를 교수자(TEACHER)·관리
|
|||
POST /eval/sessions/{id}/reevaluate — 회기 deep-loop 재평가 트리거(전체 축어록)
|
||||
GET /eval/sessions/{id}/evaluation — 회기 평가 조회(분포 + 최근 deep 결과)
|
||||
|
||||
DB(feedback_scores/supervisor_comment) SoR 적재는 Phase 2. 현재는 in-proc store + 엔진 직접 호출
|
||||
(degraded). DB 가 붙으면 조회 경로를 turns.evaluation / supervisor_comment 조인으로 교체한다.
|
||||
평가 결과는 session_persistence 의 DB-backed evaluation 저장소를 사용한다. DB 미가용 시
|
||||
in-proc cache/session fallback 은 local dev 에서만 허용한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -22,10 +22,13 @@ from typing import Annotated, Any, Optional
|
|||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import session_persistence
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
from ..runtime_policy import runtime_fallback_allowed
|
||||
from ..services import evaluator
|
||||
from ..services.evaluator import SessionEvaluation, TurnEvaluation
|
||||
from ..store import InProcSession
|
||||
from ..store import store
|
||||
|
||||
router = APIRouter(prefix="/eval", tags=["eval"])
|
||||
|
|
@ -52,14 +55,12 @@ class EvaluationSummary(BaseModel):
|
|||
distribution: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
# ── in-proc 평가 결과 캐시 (DB 적재 전 degraded 보관) ───────────────────────
|
||||
# DB 가 붙으면 turns.evaluation / supervisor_comment 로 대체. 지금은 트리거 결과를 보관해
|
||||
# 조회 GET 이 재호출 없이 마지막 deep 결과를 돌려주게 한다.
|
||||
_DEEP_CACHE: dict[str, SessionEvaluation] = {}
|
||||
|
||||
|
||||
def _load_session_or_404(session_id: str):
|
||||
sess = store.get(session_id)
|
||||
async def _load_session_or_404(session_id: str, principal: Principal) -> InProcSession:
|
||||
sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
|
||||
if sess is not None:
|
||||
store.put(sess)
|
||||
elif runtime_fallback_allowed():
|
||||
sess = store.get(session_id)
|
||||
if sess is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
|
||||
return sess
|
||||
|
|
@ -90,10 +91,10 @@ async def reevaluate_session(
|
|||
) -> SessionEvaluation:
|
||||
"""회기 전체 deep-loop 재평가(슈퍼바이저 rationale/critique + 개선점 + 대안발화).
|
||||
|
||||
in-proc store 의 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
|
||||
저장된 마스킹 축어록을 evaluator.evaluate_session 으로 평가한다.
|
||||
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
|
||||
"""
|
||||
sess = _load_session_or_404(session_id)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
masked = sess.masked_turns()
|
||||
# 발화 seq 보강(deep 프롬프트 가독성 — store 가 seq 미포함이라 인덱스로 부여)
|
||||
enriched: list[dict[str, Any]] = []
|
||||
|
|
@ -121,7 +122,16 @@ async def reevaluate_session(
|
|||
if result.error and result.error.startswith("engine_error"):
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
|
||||
|
||||
_DEEP_CACHE[session_id] = result
|
||||
await session_persistence.save_session_evaluation(
|
||||
session_id=session_id,
|
||||
learner_id=sess.learner_id,
|
||||
status="error" if result.error else "ready",
|
||||
source="engine",
|
||||
scope=result.scope,
|
||||
stage=result.stage,
|
||||
payload=result.to_dict(),
|
||||
error=result.error,
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
|
|
@ -136,10 +146,10 @@ async def reevaluate_turn(
|
|||
) -> TurnEvaluation:
|
||||
"""단일 상담자 발화 fast-loop 재평가(기법/내담자상태/적절성/의도이탈).
|
||||
|
||||
store 의 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해
|
||||
저장된 축어록에서 해당 turn_seq 상담자 발화 + 직후 내담자 응답을 재구성해
|
||||
경량 TurnContext 로 evaluator.evaluate_turn 을 호출한다.
|
||||
"""
|
||||
sess = _load_session_or_404(session_id)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
|
||||
# 대상 상담자 발화 + 직후 내담자 응답 찾기
|
||||
target_idx: Optional[int] = None
|
||||
|
|
@ -188,18 +198,20 @@ async def get_session_evaluation(
|
|||
session_id: str,
|
||||
principal: TeacherOrAdmin,
|
||||
) -> EvaluationSummary:
|
||||
"""회기 평가 조회(읽기) — 마지막 deep 재평가 결과 + 기법 분포.
|
||||
"""회기 평가 조회(읽기) — 저장된 마지막 deep 재평가 결과 + 기법 분포.
|
||||
|
||||
DB 적재 전 degraded: deep 결과는 reevaluate 트리거가 보관한 캐시에서, 분포는 그 결과에서.
|
||||
아직 평가 트리거가 없었다면 deep=None + 빈 분포.
|
||||
"""
|
||||
_load_session_or_404(session_id)
|
||||
cached = _DEEP_CACHE.get(session_id)
|
||||
if cached is None:
|
||||
await _load_session_or_404(session_id, principal)
|
||||
record, _durable = await session_persistence.load_session_evaluation(session_id, principal)
|
||||
if record is None:
|
||||
return EvaluationSummary(session_id=session_id, stage="", deep=None, distribution={})
|
||||
payload = record.get("payload")
|
||||
deep = payload if isinstance(payload, dict) else {}
|
||||
distribution = deep.get("distribution")
|
||||
return EvaluationSummary(
|
||||
session_id=session_id,
|
||||
stage=cached.stage,
|
||||
deep=cached.to_dict(),
|
||||
distribution=cached.distribution.model_dump(),
|
||||
stage=str(record.get("stage") or deep.get("stage") or ""),
|
||||
deep=deep,
|
||||
distribution=distribution if isinstance(distribution, dict) else {},
|
||||
)
|
||||
|
|
|
|||
66
apps/api/app/routes/personas.py
Normal file
66
apps/api/app/routes/personas.py
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
"""Persona catalog routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Response, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..deps import CurrentPrincipal
|
||||
from ..persona_repository import CatalogPersona, list_catalog_personas
|
||||
|
||||
router = APIRouter(prefix="/personas", tags=["personas"])
|
||||
|
||||
|
||||
class PersonaSummary(BaseModel):
|
||||
code: str
|
||||
display_name: str
|
||||
difficulty: str
|
||||
theory_target: list[str]
|
||||
demographics: dict[str, Any]
|
||||
presenting_summary: str
|
||||
voice_preset: str | None = None
|
||||
source: str = "database"
|
||||
degraded: bool = False
|
||||
|
||||
|
||||
def _first_text_value(data: dict[str, Any]) -> str:
|
||||
for value in data.values():
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _summary(entry: CatalogPersona) -> PersonaSummary:
|
||||
card = entry.card
|
||||
return PersonaSummary(
|
||||
code=card.code,
|
||||
display_name=card.display_name,
|
||||
difficulty=card.difficulty,
|
||||
theory_target=card.theory_target,
|
||||
demographics=card.demographics,
|
||||
presenting_summary=_first_text_value(card.presenting),
|
||||
source=entry.source,
|
||||
degraded=entry.degraded,
|
||||
)
|
||||
|
||||
|
||||
@router.get("", response_model=list[PersonaSummary])
|
||||
async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]:
|
||||
"""Return latest approved personas from app.persona_card."""
|
||||
try:
|
||||
personas = await list_catalog_personas()
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="persona catalog database unavailable",
|
||||
) from exc
|
||||
|
||||
if any(entry.degraded for entry in personas):
|
||||
response.headers["X-Vignette-Degraded"] = "true"
|
||||
response.headers["X-Vignette-Catalog-Source"] = "seed_fallback"
|
||||
else:
|
||||
response.headers["X-Vignette-Catalog-Source"] = "database"
|
||||
|
||||
return [_summary(entry) for entry in personas]
|
||||
File diff suppressed because it is too large
Load diff
103
apps/api/app/routes/teacher.py
Normal file
103
apps/api/app/routes/teacher.py
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"""Teacher dashboard routes backed by real server session state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from .. import session_persistence
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..store import InProcSession, store
|
||||
|
||||
router = APIRouter(prefix="/teacher", tags=["teacher"])
|
||||
|
||||
TeacherPrincipal = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
|
||||
|
||||
|
||||
class TeacherSessionSummary(BaseModel):
|
||||
session_id: str
|
||||
learner_id: str
|
||||
learner_label: str
|
||||
persona_code: str
|
||||
persona_name: str
|
||||
session_no: int
|
||||
status: str
|
||||
stage: str
|
||||
turn_count: int
|
||||
learner_turn_count: int
|
||||
client_turn_count: int
|
||||
started_at: str
|
||||
ended_at: str | None = None
|
||||
|
||||
|
||||
class TeacherDashboardResponse(BaseModel):
|
||||
source: str = "in_memory"
|
||||
cohort_label: str = "현재 학습 기록"
|
||||
total_learners: int
|
||||
active_sessions: int
|
||||
ended_sessions: int
|
||||
pending_reviews: list[TeacherSessionSummary] = Field(default_factory=list)
|
||||
recent_sessions: list[TeacherSessionSummary] = Field(default_factory=list)
|
||||
message: str
|
||||
|
||||
|
||||
def _iso(ts: float | None) -> str | None:
|
||||
if ts is None:
|
||||
return None
|
||||
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
|
||||
|
||||
|
||||
def _learner_label(learner_id: str) -> str:
|
||||
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
|
||||
return f"학습자 {suffix}"
|
||||
|
||||
|
||||
def _summary(sess: InProcSession) -> TeacherSessionSummary:
|
||||
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
|
||||
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
|
||||
return TeacherSessionSummary(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
learner_label=_learner_label(sess.learner_id),
|
||||
persona_code=sess.persona_code,
|
||||
persona_name=sess.persona.display_name,
|
||||
session_no=sess.session_no,
|
||||
status="ended" if sess.ended else "active",
|
||||
stage=sess.state.stage.value,
|
||||
turn_count=len(sess.turns),
|
||||
learner_turn_count=learner_turns,
|
||||
client_turn_count=client_turns,
|
||||
started_at=_iso(sess.created_at) or "",
|
||||
ended_at=_iso(sess.ended_at),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/dashboard", response_model=TeacherDashboardResponse)
|
||||
async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResponse:
|
||||
"""Return teacher-visible dashboard data from real sessions only."""
|
||||
sessions, durable = await session_persistence.list_sessions(principal)
|
||||
if not durable:
|
||||
require_runtime_fallback_allowed("teacher dashboard")
|
||||
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
|
||||
summaries = [_summary(sess) for sess in sessions]
|
||||
pending_reviews = [item for item in summaries if item.status == "ended"]
|
||||
learners = {sess.learner_id for sess in sessions}
|
||||
|
||||
if sessions:
|
||||
message = "현재 기록된 실제 학습 세션만 표시합니다."
|
||||
else:
|
||||
message = "아직 표시할 실제 학습자 세션이 없습니다."
|
||||
|
||||
return TeacherDashboardResponse(
|
||||
source="database" if durable else "runtime",
|
||||
total_learners=len(learners),
|
||||
active_sessions=sum(1 for sess in sessions if not sess.ended),
|
||||
ended_sessions=sum(1 for sess in sessions if sess.ended),
|
||||
pending_reviews=pending_reviews[:20],
|
||||
recent_sessions=summaries[:20],
|
||||
message=message,
|
||||
)
|
||||
280
apps/api/app/routes/users.py
Normal file
280
apps/api/app/routes/users.py
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
"""Current-user profile, preference, and voice-preset routes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user
|
||||
from ..db import get_pool
|
||||
from ..deps import CurrentPrincipal
|
||||
from ..runtime_policy import require_runtime_fallback_allowed
|
||||
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
|
||||
|
||||
router = APIRouter(prefix="/users", tags=["users"])
|
||||
|
||||
|
||||
class UserProfileResponse(BaseModel):
|
||||
user_id: str
|
||||
email: str
|
||||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
affiliation: str
|
||||
|
||||
|
||||
class UserProfilePatch(BaseModel):
|
||||
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
|
||||
|
||||
class NotificationPreferences(BaseModel):
|
||||
session_done: bool = True
|
||||
safety_signal: bool = True
|
||||
learner_progress: bool = False
|
||||
product_news: bool = False
|
||||
|
||||
|
||||
class UserPreferencesResponse(BaseModel):
|
||||
theme: str = "system"
|
||||
voice_preset_id: str = "soft-young-fem"
|
||||
voice_rate: float = 1.0
|
||||
notifications: NotificationPreferences = Field(default_factory=NotificationPreferences)
|
||||
|
||||
|
||||
class UserPreferencesPatch(BaseModel):
|
||||
theme: str | None = None
|
||||
voice_preset_id: str | None = None
|
||||
voice_rate: float | None = Field(default=None, ge=0.8, le=1.2)
|
||||
notifications: NotificationPreferences | None = None
|
||||
|
||||
|
||||
class VoicePresetResponse(BaseModel):
|
||||
id: str
|
||||
voice_id: str
|
||||
name: str
|
||||
desc: str
|
||||
persona_hint: str
|
||||
|
||||
|
||||
_preferences: dict[str, UserPreferencesResponse] = {}
|
||||
|
||||
VOICE_PRESET_META = {
|
||||
"soft-young-fem": {
|
||||
"name": "서린",
|
||||
"desc": "부드럽고 낮은 긴장감",
|
||||
"persona_hint": "청소년 내담자",
|
||||
},
|
||||
"calm-adult-male": {
|
||||
"name": "민재",
|
||||
"desc": "차분하고 안정적인 성인 남성",
|
||||
"persona_hint": "성인 남성",
|
||||
},
|
||||
"warm-adult-fem": {
|
||||
"name": "지영",
|
||||
"desc": "따뜻하지만 지친 성인 여성",
|
||||
"persona_hint": "성인 여성",
|
||||
},
|
||||
"neutral": {
|
||||
"name": "기본",
|
||||
"desc": "중립적인 기본 음성",
|
||||
"persona_hint": "범용",
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _voice_preset_ids() -> set[str]:
|
||||
return set(PRESET_TO_OPENAI_VOICE.keys())
|
||||
|
||||
|
||||
def _normalize_voice_preset(value: str | None) -> str:
|
||||
if value in _voice_preset_ids():
|
||||
return str(value)
|
||||
return "soft-young-fem"
|
||||
|
||||
|
||||
def _assert_voice_preset(value: str | None) -> None:
|
||||
if value is None:
|
||||
return
|
||||
if value not in _voice_preset_ids():
|
||||
raise HTTPException(
|
||||
status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"unsupported voice preset {value}",
|
||||
)
|
||||
|
||||
|
||||
def _voice_presets() -> list[VoicePresetResponse]:
|
||||
presets: list[VoicePresetResponse] = []
|
||||
for preset_id in PRESET_TO_OPENAI_VOICE.keys():
|
||||
meta = VOICE_PRESET_META.get(
|
||||
preset_id,
|
||||
{
|
||||
"name": preset_id,
|
||||
"desc": f"rate {PRESET_RATE.get(preset_id, 1.0):.2f}",
|
||||
"persona_hint": "사용자 지정",
|
||||
},
|
||||
)
|
||||
presets.append(
|
||||
VoicePresetResponse(
|
||||
id=preset_id,
|
||||
voice_id=preset_id,
|
||||
name=meta["name"],
|
||||
desc=meta["desc"],
|
||||
persona_hint=meta["persona_hint"],
|
||||
)
|
||||
)
|
||||
return presets
|
||||
|
||||
|
||||
def _preferences_from_row(row) -> UserPreferencesResponse:
|
||||
return UserPreferencesResponse(
|
||||
theme=row["theme"],
|
||||
voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]),
|
||||
voice_rate=float(row["voice_rate"]),
|
||||
notifications=NotificationPreferences.model_validate(row["notifications"] or {}),
|
||||
)
|
||||
|
||||
|
||||
async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
managed = await get_managed_user(principal.user_id)
|
||||
return UserProfileResponse(
|
||||
user_id=principal.user_id,
|
||||
email=principal.email,
|
||||
display_name=(
|
||||
(managed.display_name if managed else "")
|
||||
or principal.display_name
|
||||
or principal.email
|
||||
),
|
||||
role=(managed.role if managed else principal.role.value),
|
||||
cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids),
|
||||
affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION),
|
||||
)
|
||||
|
||||
|
||||
@router.get("/me", response_model=UserProfileResponse)
|
||||
async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
||||
@router.patch("/me", response_model=UserProfileResponse)
|
||||
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
|
||||
profile = await _profile_for(principal)
|
||||
await update_managed_user(
|
||||
principal.user_id,
|
||||
display_name=body.display_name if body.display_name is not None else profile.display_name,
|
||||
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
|
||||
)
|
||||
return await _profile_for(principal)
|
||||
|
||||
|
||||
@router.get("/me/preferences", response_model=UserPreferencesResponse)
|
||||
async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse:
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.user_preferences (user_id, notifications)
|
||||
VALUES ($1::uuid, $2::jsonb)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
""",
|
||||
principal.user_id,
|
||||
NotificationPreferences().model_dump(),
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT theme, voice_preset_id, voice_rate, notifications
|
||||
FROM app.user_preferences
|
||||
WHERE user_id = $1::uuid
|
||||
""",
|
||||
principal.user_id,
|
||||
)
|
||||
if row is not None:
|
||||
return _preferences_from_row(row)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("user preferences")
|
||||
return _preferences.setdefault(principal.user_id, UserPreferencesResponse())
|
||||
|
||||
|
||||
@router.patch("/me/preferences", response_model=UserPreferencesResponse)
|
||||
async def patch_preferences(
|
||||
body: UserPreferencesPatch,
|
||||
principal: CurrentPrincipal,
|
||||
) -> UserPreferencesResponse:
|
||||
_assert_voice_preset(body.voice_preset_id)
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO app.user_preferences (user_id, notifications)
|
||||
VALUES ($1::uuid, $2::jsonb)
|
||||
ON CONFLICT (user_id) DO NOTHING
|
||||
""",
|
||||
principal.user_id,
|
||||
NotificationPreferences().model_dump(),
|
||||
)
|
||||
current = await conn.fetchrow(
|
||||
"""
|
||||
SELECT theme, voice_preset_id, voice_rate, notifications
|
||||
FROM app.user_preferences
|
||||
WHERE user_id = $1::uuid
|
||||
""",
|
||||
principal.user_id,
|
||||
)
|
||||
current_prefs = _preferences_from_row(current)
|
||||
next_prefs = UserPreferencesResponse(
|
||||
theme=body.theme if body.theme is not None else current_prefs.theme,
|
||||
voice_preset_id=(
|
||||
body.voice_preset_id
|
||||
if body.voice_preset_id is not None
|
||||
else current_prefs.voice_preset_id
|
||||
),
|
||||
voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate,
|
||||
notifications=(
|
||||
body.notifications
|
||||
if body.notifications is not None
|
||||
else current_prefs.notifications
|
||||
),
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE app.user_preferences SET
|
||||
theme = $2,
|
||||
voice_preset_id = $3,
|
||||
voice_rate = $4,
|
||||
notifications = $5::jsonb,
|
||||
updated_at = now()
|
||||
WHERE user_id = $1::uuid
|
||||
RETURNING theme, voice_preset_id, voice_rate, notifications
|
||||
""",
|
||||
principal.user_id,
|
||||
next_prefs.theme,
|
||||
next_prefs.voice_preset_id,
|
||||
next_prefs.voice_rate,
|
||||
next_prefs.notifications.model_dump(),
|
||||
)
|
||||
return _preferences_from_row(row)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("user preferences")
|
||||
|
||||
current = _preferences.setdefault(principal.user_id, UserPreferencesResponse())
|
||||
data = current.model_dump()
|
||||
if body.theme is not None:
|
||||
data["theme"] = body.theme
|
||||
if body.voice_preset_id is not None:
|
||||
data["voice_preset_id"] = body.voice_preset_id
|
||||
if body.voice_rate is not None:
|
||||
data["voice_rate"] = body.voice_rate
|
||||
if body.notifications is not None:
|
||||
data["notifications"] = body.notifications.model_dump()
|
||||
next_prefs = UserPreferencesResponse.model_validate(data)
|
||||
_preferences[principal.user_id] = next_prefs
|
||||
return next_prefs
|
||||
|
||||
|
||||
@router.get("/me/voice-presets", response_model=list[VoicePresetResponse])
|
||||
async def get_voice_presets(principal: CurrentPrincipal) -> list[VoicePresetResponse]:
|
||||
return _voice_presets()
|
||||
|
|
@ -1,22 +1,14 @@
|
|||
"""음성 라우트 — OpenAI STT/TTS 캐스케이드 + WSS 실시간 턴테이킹.
|
||||
"""Voice routes for the OpenAI STT/TTS cascade over WebSocket.
|
||||
|
||||
한신대 요구 '음성 필수'. 학습자가 마이크로 말하면 → STT → orchestrator 상담 1턴 →
|
||||
내담자 텍스트 → TTS 오디오 + 립싱크 힌트(설계 §4.3 RMS)를 역방향으로 흘린다.
|
||||
Client sends JSON controls plus binary audio chunks:
|
||||
audio_start -> binary audio chunks -> audio_end
|
||||
|
||||
캐스케이드(설계 §5.2 음성 오브 4상태 listening→thinking→speaking→idle):
|
||||
[클라] audio_start(JSON) → 바이너리 오디오 청크들 → audio_end(JSON)
|
||||
[서버] state(listening) → STT → transcript(JSON) → state(thinking)
|
||||
→ orchestrator.run_turn(가드레일·상태머신·페르소나·내담자AI·출력가드)
|
||||
→ reply(JSON, 내담자 텍스트 + stage/openness) → state(speaking)
|
||||
→ [tts_chunk(JSON: seq/rms) + 바이너리 오디오] × N → tts_end(JSON) → state(idle)
|
||||
Server emits:
|
||||
ready -> state(listening) -> state(thinking) -> transcript -> reply
|
||||
-> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)
|
||||
|
||||
프로토콜(JSON 제어 + 바이너리 오디오 혼합, 단일 WS):
|
||||
- 클라→서버 텍스트 = JSON 제어({"type": ...}); 클라→서버 바이너리 = 오디오 청크
|
||||
- 서버→클라 텍스트 = JSON 이벤트; 서버→클라 바이너리 = TTS 오디오 청크
|
||||
- 각 TTS 바이너리 청크 *직전*에 메타 JSON(tts_chunk: seq, rms)을 보내 프론트가 짝짓는다.
|
||||
|
||||
음성 미설정(OPENAI_API_KEY 없음): GET /voice/health → 503 degraded,
|
||||
WS 는 핸드셰이크 직후 degraded 이벤트 + close(1011). 절대 크래시 금지.
|
||||
When voice is not configured, the route reports degraded state and closes
|
||||
cleanly instead of crashing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -28,67 +20,84 @@ from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
|||
from fastapi.responses import JSONResponse
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
from .. import session_persistence
|
||||
from ..auth_sessions import get_session
|
||||
from ..config import settings
|
||||
from ..deps import Principal, Role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
from ..services import memory, orchestrator, persona
|
||||
from ..persona_repository import get_catalog_persona
|
||||
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
||||
from ..services import memory, orchestrator, state_machine
|
||||
from ..services import voice as voice_svc
|
||||
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
|
||||
from ..store import TurnRecord, store
|
||||
from ..store import InProcSession, TurnRecord, store
|
||||
|
||||
router = APIRouter(prefix="/voice", tags=["voice"])
|
||||
|
||||
# WS close 코드(섹션별 의미 명시)
|
||||
WS_CLOSE_DEGRADED = 1011 # 서버측 음성 미설정/장애
|
||||
WS_CLOSE_BAD_REQUEST = 1008 # 프로토콜 위반(세션 누락 등)
|
||||
# WebSocket close codes.
|
||||
WS_CLOSE_DEGRADED = 1011
|
||||
WS_CLOSE_BAD_REQUEST = 1008
|
||||
WS_CLOSE_UNAUTHORIZED = 1008
|
||||
|
||||
# 한 발화당 누적 오디오 상한(메모리 방어, ~10MB)
|
||||
# Per-utterance audio cap to avoid unbounded memory growth.
|
||||
_MAX_AUDIO_BYTES = 10 * 1024 * 1024
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 헬스 — 음성 가용성(키 설정) 노출
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
@router.get("/health")
|
||||
async def voice_health() -> JSONResponse:
|
||||
"""음성 라우터 헬스. 키 미설정이면 503 degraded(시연 투명성)."""
|
||||
"""Return voice service readiness."""
|
||||
available = voice_service.is_available()
|
||||
body = {
|
||||
"status": "ok" if available else "degraded",
|
||||
"available": available,
|
||||
"stt_model": voice_svc.STT_MODEL,
|
||||
"tts_model": voice_svc.TTS_MODEL,
|
||||
"reason": None if available else "OPENAI_API_KEY 미설정",
|
||||
"reason": None if available else "OPENAI_API_KEY is not configured",
|
||||
}
|
||||
return JSONResponse(body, status_code=200 if available else 503)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# WebSocket — 실시간 음성 캐스케이드
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
@router.websocket("/ws")
|
||||
async def voice_ws(websocket: WebSocket) -> None:
|
||||
"""음성 실시간 턴 캐스케이드.
|
||||
|
||||
쿼리: ?session_id=<hex> (없으면 persona_code 로 일회용 in-proc 세션 생성 — 시연용)
|
||||
오디오 in(바이너리) → STT → 상담 1턴 → TTS out(바이너리) + 립싱크 힌트.
|
||||
"""
|
||||
"""Run one authenticated learner voice cascade."""
|
||||
await websocket.accept()
|
||||
|
||||
# 1) 음성 미설정 → degraded 알리고 정상 종료(크래시 금지)
|
||||
if not voice_service.is_available():
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{"type": "degraded", "reason": "OPENAI_API_KEY 미설정 — 음성 기능 비활성"},
|
||||
)
|
||||
await _safe_close(websocket, WS_CLOSE_DEGRADED)
|
||||
# Authenticate the same server-side browser session used by REST routes.
|
||||
principal = await _principal_from_websocket(websocket)
|
||||
if principal is None:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
|
||||
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
|
||||
return
|
||||
if principal.role != Role.LEARNER:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
|
||||
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
|
||||
return
|
||||
|
||||
# 2) 세션 바인딩 — session_id 우선, 없으면 persona_code 로 시연 세션 생성
|
||||
session_id, voice_preset, err = _bind_session(websocket)
|
||||
# Bind to an existing session first. persona_code creation is dev-only.
|
||||
session_id, voice_preset, err, bind_meta = await _bind_session(websocket, principal)
|
||||
if err is not None:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": err})
|
||||
await _safe_close(websocket, WS_CLOSE_BAD_REQUEST)
|
||||
return
|
||||
assert session_id is not None and voice_preset is not None
|
||||
if bind_meta.get("degraded"):
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
"type": "degraded",
|
||||
"reason": bind_meta.get("degraded_reason", "voice session binding degraded"),
|
||||
**bind_meta,
|
||||
},
|
||||
)
|
||||
|
||||
# Voice misconfiguration is reported explicitly and then closed cleanly.
|
||||
if not voice_service.is_available():
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{"type": "degraded", "reason": "OPENAI_API_KEY is not configured"},
|
||||
)
|
||||
await _safe_close(websocket, WS_CLOSE_DEGRADED)
|
||||
return
|
||||
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
|
|
@ -98,6 +107,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
"voice": voice_preset.openai_voice,
|
||||
"preset": voice_preset.preset,
|
||||
"state": "idle",
|
||||
**bind_meta,
|
||||
},
|
||||
)
|
||||
|
||||
|
|
@ -111,10 +121,10 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
if mtype == "websocket.disconnect":
|
||||
break
|
||||
|
||||
# ── 바이너리 = 오디오 청크 누적 ──
|
||||
# Binary frames are audio chunks.
|
||||
if msg.get("bytes") is not None:
|
||||
if not receiving:
|
||||
# audio_start 없이 들어온 바이너리 — 관용적으로 자동 시작
|
||||
# Be tolerant when audio arrives before audio_start.
|
||||
receiving = True
|
||||
audio_buf.clear()
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
|
||||
|
|
@ -122,13 +132,13 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
if len(audio_buf) > _MAX_AUDIO_BYTES:
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{"type": "error", "detail": "audio too large — 발화를 짧게 끊어 주세요"},
|
||||
{"type": "error", "detail": "audio too large; please send a shorter utterance"},
|
||||
)
|
||||
audio_buf.clear()
|
||||
receiving = False
|
||||
continue
|
||||
|
||||
# ── 텍스트 = JSON 제어 ──
|
||||
# Text frames are JSON controls.
|
||||
text = msg.get("text")
|
||||
if text is None:
|
||||
continue
|
||||
|
|
@ -149,6 +159,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
await _handle_utterance(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
voice_preset=voice_preset,
|
||||
audio=bytes(audio_buf),
|
||||
fmt=ctrl.get("format"),
|
||||
|
|
@ -156,7 +167,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
audio_buf.clear()
|
||||
|
||||
elif ctype == "text_turn":
|
||||
# 음성 없이 텍스트만 보내는 경로(접근성/디버그): STT 건너뛰고 바로 턴.
|
||||
# Text-only path for accessibility and deterministic tests.
|
||||
receiving = False
|
||||
audio_buf.clear()
|
||||
learner_text = (ctrl.get("text") or "").strip()
|
||||
|
|
@ -164,6 +175,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
await _run_turn_and_speak(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
voice_preset=voice_preset,
|
||||
learner_text=learner_text,
|
||||
)
|
||||
|
|
@ -176,30 +188,28 @@ async def voice_ws(websocket: WebSocket) -> None:
|
|||
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception as e: # 어떤 예외도 WS 를 깨끗이 닫고 알린다(크래시 금지)
|
||||
except Exception as e:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
|
||||
finally:
|
||||
await _safe_close(websocket)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 발화 1건 처리 — STT → 턴 → TTS
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
async def _handle_utterance(
|
||||
websocket: WebSocket,
|
||||
*,
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
voice_preset: VoicePreset,
|
||||
audio: bytes,
|
||||
fmt: Optional[str],
|
||||
) -> None:
|
||||
"""오디오 1발화 → STT → 상담 턴 → TTS 캐스케이드."""
|
||||
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
|
||||
if not audio:
|
||||
await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
# 1) STT (thinking 진입)
|
||||
# STT begins after the learner stops speaking.
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
|
||||
filename, content_type = _audio_meta(fmt)
|
||||
try:
|
||||
|
|
@ -211,7 +221,7 @@ async def _handle_utterance(
|
|||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
except Exception as e:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": f"STT 실패: {e}"})
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": f"STT failed: {e}"})
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
|
|
@ -221,13 +231,13 @@ async def _handle_utterance(
|
|||
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
|
||||
)
|
||||
if not learner_text:
|
||||
# 무음/인식 실패 — 턴 진행 안 함
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
await _run_turn_and_speak(
|
||||
websocket,
|
||||
session_id=session_id,
|
||||
principal=principal,
|
||||
voice_preset=voice_preset,
|
||||
learner_text=learner_text,
|
||||
)
|
||||
|
|
@ -237,13 +247,14 @@ async def _run_turn_and_speak(
|
|||
websocket: WebSocket,
|
||||
*,
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
voice_preset: VoicePreset,
|
||||
learner_text: str,
|
||||
) -> None:
|
||||
"""상담 1턴(orchestrator) → 내담자 텍스트 → TTS 오디오/립싱크 힌트 역방향 전송."""
|
||||
sess = store.get(session_id)
|
||||
if sess is None or sess.ended:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": "세션 없음/종료됨"})
|
||||
"""Run one counseling turn and stream synthesized client speech."""
|
||||
sess, err = await _load_voice_session(session_id, principal)
|
||||
if sess is None:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": err or "session not found or ended"})
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
|
|
@ -260,19 +271,7 @@ async def _run_turn_and_speak(
|
|||
)
|
||||
assert ctx.state_after is not None
|
||||
|
||||
# 학습자 발화 로깅(마스킹본) — sessions.py 패턴과 동일
|
||||
store.append_turn(
|
||||
session_id,
|
||||
TurnRecord(
|
||||
turn_seq=ctx.state_after.turn_seq,
|
||||
speaker="counselor",
|
||||
stage=ctx.state_after.stage.value,
|
||||
text=learner_text,
|
||||
text_masked=ctx.learner_text_masked,
|
||||
),
|
||||
)
|
||||
|
||||
# 2) 내담자 AI 1턴(동기 — 음성은 TTS 전 전체 텍스트가 필요)
|
||||
# Voice needs the full client reply before TTS starts.
|
||||
try:
|
||||
result = await orchestrator.run_turn_generate(ctx, engine_client)
|
||||
except EngineError as e:
|
||||
|
|
@ -281,10 +280,22 @@ async def _run_turn_and_speak(
|
|||
return
|
||||
|
||||
reply = result.client_reply or ""
|
||||
# 내담자 응답 로깅 + 상태 체크포인트
|
||||
# Persist only after the client reply has been generated. A failed AI turn
|
||||
# must not leave a learner-only transcript in review or history.
|
||||
await _append_voice_turn(
|
||||
sess,
|
||||
TurnRecord(
|
||||
turn_seq=ctx.state_after.turn_seq,
|
||||
speaker="counselor",
|
||||
stage=ctx.state_after.stage.value,
|
||||
text=learner_text,
|
||||
text_masked=ctx.learner_text_masked,
|
||||
),
|
||||
)
|
||||
if reply:
|
||||
store.append_turn(
|
||||
session_id,
|
||||
# Persist the generated client reply before TTS playback.
|
||||
await _append_voice_turn(
|
||||
sess,
|
||||
TurnRecord(
|
||||
turn_seq=result.turn_seq,
|
||||
speaker="client",
|
||||
|
|
@ -293,9 +304,9 @@ async def _run_turn_and_speak(
|
|||
text_masked=reply,
|
||||
),
|
||||
)
|
||||
store.update_state(session_id, result.state_after)
|
||||
await _update_voice_state(sess, result.state_after)
|
||||
|
||||
# 내담자 텍스트 이벤트(설계 §5.3 자막 — partial 없이 final)
|
||||
# Send the final client text before audio playback.
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{
|
||||
|
|
@ -314,7 +325,7 @@ async def _run_turn_and_speak(
|
|||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
return
|
||||
|
||||
# 3) TTS (speaking) — 청크별 메타 JSON(립싱크 rms) + 바이너리 오디오
|
||||
# TTS speaking state comes before chunk metadata and binary audio.
|
||||
await _safe_send_json(
|
||||
websocket,
|
||||
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
|
||||
|
|
@ -322,7 +333,7 @@ async def _run_turn_and_speak(
|
|||
try:
|
||||
n = 0
|
||||
async for ck in voice_service.synthesize_stream(reply, voice_preset):
|
||||
# 메타 먼저(프론트가 직후 바이너리와 짝지음) — 설계 §4.3 RMS 1채널
|
||||
# Metadata precedes the binary chunk so the client can pair them.
|
||||
await _safe_send_json(
|
||||
websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)}
|
||||
)
|
||||
|
|
@ -332,46 +343,112 @@ async def _run_turn_and_speak(
|
|||
except VoiceUnavailable as e:
|
||||
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
|
||||
except Exception as e:
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": f"TTS 실패: {e}"})
|
||||
await _safe_send_json(websocket, {"type": "error", "detail": f"TTS failed: {e}"})
|
||||
|
||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 세션 바인딩 / 메타 헬퍼
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
def _bind_session(
|
||||
websocket: WebSocket,
|
||||
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str]]:
|
||||
"""쿼리에서 세션을 바인딩(또는 시연 세션 생성)하고 voice preset 을 해석.
|
||||
async def _load_voice_session(
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
) -> tuple[InProcSession | None, str | None]:
|
||||
sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
|
||||
if sess is not None:
|
||||
store.put(sess)
|
||||
elif runtime_fallback_allowed():
|
||||
sess = store.get(session_id)
|
||||
if sess is None:
|
||||
return None, f"unknown session {session_id}"
|
||||
if sess.learner_id != principal.user_id:
|
||||
return None, "session does not belong to user"
|
||||
if sess.ended:
|
||||
return None, "session already ended"
|
||||
return sess, None
|
||||
|
||||
우선순위:
|
||||
?session_id=<hex> — 기존 세션(REST 로 시작된)에 음성 부착
|
||||
?persona_code=P1[&preset=] — in-proc 시연 세션 생성(DB off 폴백)
|
||||
반환 (session_id, voice_preset, error).
|
||||
"""
|
||||
|
||||
async def _append_voice_turn(sess: InProcSession, turn: TurnRecord) -> None:
|
||||
if await session_persistence.append_turn(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
turn=turn,
|
||||
):
|
||||
sess.turns.append(turn)
|
||||
store.put(sess)
|
||||
return
|
||||
require_runtime_fallback_allowed("voice session turn append")
|
||||
store.append_turn(sess.session_id, turn)
|
||||
|
||||
|
||||
async def _update_voice_state(
|
||||
sess: InProcSession,
|
||||
state: state_machine.SessionState,
|
||||
) -> None:
|
||||
if await session_persistence.update_state(
|
||||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
state=state,
|
||||
):
|
||||
sess.state = state
|
||||
store.put(sess)
|
||||
return
|
||||
require_runtime_fallback_allowed("voice session state update")
|
||||
store.update_state(sess.session_id, state)
|
||||
|
||||
|
||||
async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
|
||||
"""Restore the same server-side browser session used by REST routes."""
|
||||
raw_cookie = websocket.cookies.get(settings.cookie_name)
|
||||
if raw_cookie is None and settings.environment == "dev":
|
||||
raw_cookie = websocket.cookies.get("vignette_sid")
|
||||
|
||||
session = await get_session(raw_cookie)
|
||||
if session is None:
|
||||
return None
|
||||
|
||||
try:
|
||||
role = Role(session.role)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
return Principal(
|
||||
user_id=session.user_id,
|
||||
role=role,
|
||||
cohort_ids=session.cohort_ids,
|
||||
email=session.email,
|
||||
display_name=session.display_name,
|
||||
)
|
||||
|
||||
|
||||
async def _bind_session(
|
||||
websocket: WebSocket,
|
||||
principal: Principal,
|
||||
) -> tuple[Optional[str], Optional[VoicePreset], Optional[str], dict[str, object]]:
|
||||
"""Bind an existing session or create a dev-only voice session."""
|
||||
qp = websocket.query_params
|
||||
explicit_preset = qp.get("preset")
|
||||
|
||||
session_id = qp.get("session_id")
|
||||
if session_id:
|
||||
sess = store.get(session_id)
|
||||
sess, err = await _load_voice_session(session_id, principal)
|
||||
if sess is None:
|
||||
return None, None, f"unknown session {session_id}"
|
||||
if sess.ended:
|
||||
return None, None, "session already ended"
|
||||
return None, None, err or f"unknown session {session_id}", {}
|
||||
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
|
||||
return session_id, vp, None
|
||||
return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
|
||||
|
||||
# persona_code session creation is local-dev only. Production uses REST start.
|
||||
if settings.environment != "dev":
|
||||
return None, None, "session_id required", {}
|
||||
|
||||
# persona_code 로 시연 세션 생성(REST 미경유 음성 단독 데모)
|
||||
persona_code = qp.get("persona_code")
|
||||
if not persona_code:
|
||||
return None, None, "session_id 또는 persona_code 쿼리 필요"
|
||||
card = persona.get_seed_persona(persona_code)
|
||||
if card is None:
|
||||
return None, None, f"unknown persona {persona_code}"
|
||||
|
||||
from ..services import state_machine
|
||||
return None, None, "session_id or persona_code query required", {}
|
||||
try:
|
||||
catalog_persona = await get_catalog_persona(persona_code)
|
||||
except Exception:
|
||||
return None, None, "persona catalog database unavailable", {}
|
||||
if catalog_persona is None:
|
||||
return None, None, f"unknown persona {persona_code}", {}
|
||||
card = catalog_persona.card
|
||||
|
||||
st = state_machine.init_state(
|
||||
base_resistance=card.base_resistance(),
|
||||
|
|
@ -379,19 +456,47 @@ def _bind_session(
|
|||
decay_floor=card.decay_floor(),
|
||||
ideation_baseline=card.ideation_baseline(),
|
||||
)
|
||||
sess = store.create(
|
||||
learner_id="dev-learner-voice",
|
||||
persona=card,
|
||||
sess = await session_persistence.create_session(
|
||||
learner_id=principal.user_id,
|
||||
card=card,
|
||||
theory_mode="humanistic",
|
||||
state=st,
|
||||
session_no=1,
|
||||
carry_rapport=st.rapport_credit,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
)
|
||||
session_source = "database"
|
||||
if sess is None:
|
||||
require_runtime_fallback_allowed("voice session creation")
|
||||
sess = store.create(
|
||||
learner_id=principal.user_id,
|
||||
persona=card,
|
||||
theory_mode="humanistic",
|
||||
state=st,
|
||||
session_no=1,
|
||||
carry_rapport=st.rapport_credit,
|
||||
)
|
||||
session_source = "runtime"
|
||||
else:
|
||||
store.put(sess)
|
||||
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
|
||||
return sess.session_id, vp, None
|
||||
degraded_reasons: list[str] = []
|
||||
if catalog_persona.degraded:
|
||||
degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
|
||||
if session_source == "runtime":
|
||||
degraded_reasons.append("세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다")
|
||||
bind_meta = {
|
||||
"degraded": bool(degraded_reasons),
|
||||
"degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None,
|
||||
"persona_catalog_source": catalog_persona.source,
|
||||
"session_source": session_source,
|
||||
}
|
||||
return sess.session_id, vp, None, bind_meta
|
||||
|
||||
|
||||
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
|
||||
"""클라가 알려준 포맷 → (filename, content_type). 기본 webm/opus."""
|
||||
"""Map the browser audio format to upload metadata."""
|
||||
f = (fmt or "webm").lower().lstrip(".")
|
||||
table = {
|
||||
"webm": ("audio.webm", "audio/webm"),
|
||||
|
|
@ -406,7 +511,6 @@ def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
|
|||
return table.get(f, ("audio.webm", "audio/webm"))
|
||||
|
||||
|
||||
# ── 안전 송수신(연결 끊김 시 조용히 무시) ───────────────────────────────────
|
||||
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
|
||||
if websocket.client_state != WebSocketState.CONNECTED:
|
||||
return
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue