Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

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

View file

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

View file

@ -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 {},
)

View 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

View 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,
)

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

View file

@ -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상태 listeningthinkingspeakingidle):
[클라] 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