동의 게이트와 런타임 안정화

This commit is contained in:
Yun Chan 2026-06-27 17:22:38 +09:00
parent 0eb7d925ed
commit 0ec266a761
34 changed files with 1186 additions and 158 deletions

View file

@ -28,6 +28,7 @@ class SessionUser:
display_name: str
role: str
cohort_ids: list[str]
consent_at: float | None
expires_at: float
@ -39,6 +40,7 @@ class ManagedUser:
role: str
cohort_ids: list[str]
affiliation: str
consent_at: float | None
created_at: float
last_seen_at: float
@ -95,6 +97,17 @@ def _ts(value: datetime | None) -> float:
return (value or datetime.now(timezone.utc)).timestamp()
def _optional_ts(value: datetime | None) -> float | None:
return value.timestamp() if value is not None else None
def _row_value(row, key: str, default=None):
try:
return row[key]
except (IndexError, KeyError, TypeError):
return default
def _cohort_ids(cohort: str | None) -> list[str]:
return [cohort] if cohort else []
@ -111,9 +124,9 @@ async def _runtime_tables_ready(conn) -> bool:
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'app_user'
AND column_name IN ('affiliation', 'last_seen_at', 'updated_at')
AND column_name IN ('affiliation', 'consent_at', 'last_seen_at', 'updated_at')
GROUP BY table_schema, table_name
HAVING count(*) = 3
HAVING count(*) = 4
) AS has_user_columns,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
@ -214,6 +227,7 @@ async def ensure_runtime_tables() -> None:
"""
ALTER TABLE app.app_user
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
ADD COLUMN IF NOT EXISTS consent_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
"""
@ -388,8 +402,9 @@ def _managed_user_from_row(row) -> ManagedUser:
email=row["email"],
display_name=row["display_name"] or row["email"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
cohort_ids=_cohort_ids(_row_value(row, "cohort")),
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
consent_at=_optional_ts(_row_value(row, "consent_at")),
created_at=_ts(row["created_at"]),
last_seen_at=_ts(row["last_seen_at"]),
)
@ -403,6 +418,7 @@ def _memory_upsert_managed_user(
cohort_ids: list[str] | None = None,
user_id: str | None = None,
affiliation: str | None = None,
consent_at: float | None = None,
reactivate: bool = False,
) -> ManagedUser:
now = time.time()
@ -424,6 +440,7 @@ def _memory_upsert_managed_user(
if affiliation
else (current.affiliation if current else DEFAULT_AFFILIATION)
),
consent_at=consent_at if consent_at is not None else (current.consent_at if current else None),
created_at=current.created_at if current else now,
last_seen_at=now,
)
@ -464,7 +481,7 @@ async def upsert_managed_user(
last_seen_at = now(),
updated_at = now()
WHERE app.app_user.is_active OR $7
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
""",
normalized_external_id,
normalized_email,
@ -485,6 +502,7 @@ async def upsert_managed_user(
cohort_ids=user.cohort_ids,
user_id=user.user_id,
affiliation=user.affiliation,
consent_at=user.consent_at,
reactivate=True,
)
return user
@ -509,7 +527,7 @@ async def get_managed_user(user_id: str) -> ManagedUser | None:
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
FROM app.app_user
WHERE user_id = $1::uuid AND is_active
""",
@ -530,7 +548,7 @@ async def list_managed_users() -> tuple[list[ManagedUser], bool]:
async with pool.acquire() as conn:
rows = await conn.fetch(
"""
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
FROM app.app_user
WHERE is_active
ORDER BY last_seen_at DESC
@ -563,7 +581,7 @@ async def update_managed_user(
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
""",
user_id,
display_name.strip() if display_name is not None else None,
@ -581,12 +599,14 @@ async def update_managed_user(
cohort_ids=next_user.cohort_ids,
user_id=next_user.user_id,
affiliation=next_user.affiliation,
consent_at=next_user.consent_at,
)
for session in _sessions.values():
if session.user_id == user_id:
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
return next_user
except Exception:
require_runtime_fallback_allowed("managed user update")
@ -604,6 +624,7 @@ async def update_managed_user(
role=role if role is not None else current.role,
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
consent_at=current.consent_at,
created_at=current.created_at,
last_seen_at=time.time(),
)
@ -614,9 +635,118 @@ async def update_managed_user(
session.display_name = next_user.display_name
session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
return next_user
async def record_user_consent(user_id: str) -> float | None:
"""Mark a learner consent receipt timestamp for the current user."""
try:
pool = get_pool()
async with pool.acquire() as conn:
row = await conn.fetchrow(
"""
UPDATE app.app_user SET
consent_at = now(),
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
RETURNING consent_at
""",
user_id,
)
if row is not None:
consent_at = _optional_ts(row["consent_at"])
memory_user = _users.get(user_id)
if memory_user is not None:
_users[user_id] = ManagedUser(
user_id=memory_user.user_id,
email=memory_user.email,
display_name=memory_user.display_name,
role=memory_user.role,
cohort_ids=list(memory_user.cohort_ids),
affiliation=memory_user.affiliation,
consent_at=consent_at,
created_at=memory_user.created_at,
last_seen_at=time.time(),
)
for session in _sessions.values():
if session.user_id == user_id:
session.consent_at = consent_at
return consent_at
except Exception:
require_runtime_fallback_allowed("consent update")
if not runtime_fallback_allowed():
return None
current = _users.get(user_id)
if current is None:
return None
consent_at = time.time()
_users[user_id] = ManagedUser(
user_id=current.user_id,
email=current.email,
display_name=current.display_name,
role=current.role,
cohort_ids=list(current.cohort_ids),
affiliation=current.affiliation,
consent_at=consent_at,
created_at=current.created_at,
last_seen_at=consent_at,
)
for session in _sessions.values():
if session.user_id == user_id:
session.consent_at = consent_at
return consent_at
async def withdraw_user_consent(user_id: str) -> bool:
"""Clear consent and revoke practice eligibility until the user consents again."""
changed = False
try:
pool = get_pool()
async with pool.acquire() as conn:
result = await conn.execute(
"""
UPDATE app.app_user SET
consent_at = NULL,
updated_at = now(),
last_seen_at = now()
WHERE user_id = $1::uuid AND is_active
""",
user_id,
)
changed = result.endswith(" 1")
except Exception:
require_runtime_fallback_allowed("consent withdrawal")
if not runtime_fallback_allowed():
return changed
current = _users.get(user_id)
if current is not None:
_users[user_id] = ManagedUser(
user_id=current.user_id,
email=current.email,
display_name=current.display_name,
role=current.role,
cohort_ids=list(current.cohort_ids),
affiliation=current.affiliation,
consent_at=None,
created_at=current.created_at,
last_seen_at=time.time(),
)
changed = True
for session in _sessions.values():
if session.user_id == user_id:
session.consent_at = None
return changed
async def user_has_consent(user_id: str) -> bool:
managed = await get_managed_user(user_id)
return bool(managed and managed.consent_at is not None)
async def deactivate_managed_user(user_id: str) -> bool:
"""Deactivate a managed user and revoke their active browser sessions."""
changed = False
@ -718,6 +848,7 @@ async def create_session(
display_name=managed.display_name,
role=managed.role,
cohort_ids=list(managed.cohort_ids),
consent_at=managed.consent_at,
expires_at=expires_at,
)
sid_hash = _sid_hash(raw_sid)
@ -767,7 +898,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
u.email,
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
u.role,
u.cohort
u.cohort,
u.consent_at
FROM app.auth_session s
JOIN app.app_user u ON u.user_id = s.user_id
WHERE s.sid_hash = $1
@ -792,6 +924,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
display_name=row["display_name"],
role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row["cohort"]),
consent_at=_optional_ts(row["consent_at"]),
expires_at=_ts(row["expires_at"]),
)
except Exception:

View file

@ -35,12 +35,14 @@ class Principal:
cohort_ids: Optional[list[str]] = None,
email: str = "",
display_name: str = "",
consent_at: float | None = None,
) -> None:
self.user_id = user_id
self.role = role
self.cohort_ids = cohort_ids or []
self.email = email
self.display_name = display_name
self.consent_at = consent_at
def get_settings_dep() -> Settings:
@ -74,6 +76,7 @@ async def get_current_principal(
cohort_ids=session.cohort_ids,
email=session.email,
display_name=session.display_name,
consent_at=session.consent_at,
)

View file

@ -25,7 +25,14 @@ from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response,
from fastapi.responses import RedirectResponse
from pydantic import BaseModel
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
from ..auth_sessions import (
InactiveUserError,
SessionUser,
create_session,
record_user_consent,
revoke_session,
withdraw_user_consent,
)
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
from ..saml import (
@ -71,6 +78,15 @@ class MeResponse(BaseModel):
display_name: str
role: str
cohort_ids: list[str]
consent_at: float | None = None
class ConsentRequest(BaseModel):
accepted: bool = True
class ConsentResponse(BaseModel):
consent_at: float | None = None
class AuthProviderStatus(BaseModel):
@ -564,6 +580,7 @@ def _me_response(user: SessionUser | Principal) -> MeResponse:
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,
consent_at=getattr(user, "consent_at", None),
)
@ -933,6 +950,35 @@ async def logout(
return {"ok": True}
@router.post("/consent", response_model=ConsentResponse)
async def accept_consent(
body: ConsentRequest,
principal: CurrentPrincipal,
) -> ConsentResponse:
"""Record the current learner's practice-session consent receipt."""
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
if not body.accepted:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="consent_not_accepted")
consent_at = await record_user_consent(principal.user_id)
if consent_at is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
principal.consent_at = consent_at
return ConsentResponse(consent_at=consent_at)
@router.delete("/consent", response_model=ConsentResponse)
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
"""Withdraw practice-session consent until the learner accepts again."""
if principal.role != Role.LEARNER:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
changed = await withdraw_user_consent(principal.user_id)
if not changed:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
principal.consent_at = None
return ConsentResponse(consent_at=None)
@router.get("/me", response_model=MeResponse)
async def me(principal: CurrentPrincipal) -> MeResponse:
"""Return the current authenticated user. Unauthenticated requests are 401."""

View file

@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime
from ..auth_sessions import user_has_consent
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client
@ -240,6 +241,7 @@ class SessionReviewResponse(BaseModel):
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
_KB_CUES_CACHE: dict[str, list[str]] = {}
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
_LEARNER_VISIBLE_AI_ROLE = "counselor"
# ────────────────────────────────────────────────────────────────────────────
@ -409,14 +411,15 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
BGE-M3 임베더 로드(~ ) 회기 시작/ 응답을 막지 않도록 create_task로 띄운다.
warm 완료 턴은 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 구간 비치명적.
"""
try:
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
except Exception:
pass
try:
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
except Exception:
pass
async with _RAG_WARM_SEMAPHORE:
try:
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
except Exception:
pass
try:
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
except Exception:
pass
_PHASE_KEY_BY_LABEL = {
@ -436,6 +439,14 @@ def _ensure_learner(principal: Principal) -> None:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
async def _ensure_practice_consent(principal: Principal) -> None:
if principal.consent_at is not None:
return
if await user_has_consent(principal.user_id):
return
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="consent_required")
async def _load_session_or_404(
session_id: str,
principal: Principal,
@ -1149,6 +1160,7 @@ async def start_session(
) -> SessionStartResponse:
"""Start a learner-owned practice session."""
_ensure_learner(principal)
await _ensure_practice_consent(principal)
try:
catalog_persona = await get_catalog_persona(body.persona_code)

View file

@ -23,7 +23,7 @@ from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
from .. import session_persistence, turn_runtime
from ..auth_sessions import get_session
from ..auth_sessions import get_session, user_has_consent
from ..config import settings
from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client
@ -431,6 +431,7 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
cohort_ids=session.cohort_ids,
email=session.email,
display_name=session.display_name,
consent_at=session.consent_at,
)
@ -457,6 +458,8 @@ async def _bind_session(
persona_code = qp.get("persona_code")
if not persona_code:
return None, None, "session_id or persona_code query required", {}
if principal.consent_at is None and not await user_has_consent(principal.user_id):
return None, None, "consent_required", {}
try:
catalog_persona = await get_catalog_persona(persona_code)
except Exception:

View file

@ -23,6 +23,7 @@ from __future__ import annotations
import asyncio
import json
import threading
import time
from dataclasses import dataclass, field
from enum import Enum
@ -159,6 +160,7 @@ CRAG_TOP1_THRESHOLD = 0.35
# ════════════════════════════════════════════════════════════════════════════
_EMBEDDER: Any = None # FlagEmbedding.BGEM3FlagModel 인스턴스(지연 로딩 캐시)
_EMBEDDER_FAILED = False # 모델 로드 실패 1회 기록(반복 시도 방지)
_EMBEDDER_LOCK = threading.RLock()
BGE_M3_MODEL = "BAAI/bge-m3"
EMBED_DIM = 1024 # kb.chunk.embedding vector(1024) 와 정합 — 어기면 DB 캐스트 실패
@ -171,22 +173,25 @@ def _get_embedder() -> Any:
global _EMBEDDER, _EMBEDDER_FAILED
if _EMBEDDER is not None:
return _EMBEDDER
if _EMBEDDER_FAILED:
raise NotConfigured("BGE-M3 embedder unavailable (prior load failure)")
try:
from FlagEmbedding import BGEM3FlagModel # 무거운 의존성 — 지연 import
except Exception as e: # ImportError 포함(미설치 환경)
_EMBEDDER_FAILED = True
raise NotConfigured(
"FlagEmbedding(BGE-M3) not installed — requirements-rag.txt 필요"
) from e
try:
# use_fp16: GPU 시 절반정밀(속도). CPU 면 무시됨.
_EMBEDDER = BGEM3FlagModel(BGE_M3_MODEL, use_fp16=True)
except Exception as e:
_EMBEDDER_FAILED = True
raise NotConfigured(f"BGE-M3 model load failed: {e}") from e
return _EMBEDDER
with _EMBEDDER_LOCK:
if _EMBEDDER is not None:
return _EMBEDDER
if _EMBEDDER_FAILED:
raise NotConfigured("BGE-M3 embedder unavailable (prior load failure)")
try:
from FlagEmbedding import BGEM3FlagModel # 무거운 의존성 — 지연 import
except Exception as e: # ImportError 포함(미설치 환경)
_EMBEDDER_FAILED = True
raise NotConfigured(
"FlagEmbedding(BGE-M3) not installed — requirements-rag.txt 필요"
) from e
try:
# use_fp16: GPU 시 절반정밀(속도). CPU 면 무시됨.
_EMBEDDER = BGEM3FlagModel(BGE_M3_MODEL, use_fp16=True)
except Exception as e:
_EMBEDDER_FAILED = True
raise NotConfigured(f"BGE-M3 model load failed: {e}") from e
return _EMBEDDER
@dataclass(slots=True)
@ -202,13 +207,14 @@ def embed_query(text: str) -> EmbeddedQuery:
인덱싱 시점(오프라인 배치)에도 같은 함수로 청크 임베딩을 산출한다(동일 모델 재사용).
"""
model = _get_embedder()
out = model.encode(
[text],
return_dense=True,
return_sparse=True,
return_colbert_vecs=False, # 멀티벡터는 런타임 회수에 미사용(인덱싱만)
)
with _EMBEDDER_LOCK:
model = _get_embedder()
out = model.encode(
[text],
return_dense=True,
return_sparse=True,
return_colbert_vecs=False, # 멀티벡터는 런타임 회수에 미사용(인덱싱만)
)
dense_vec = out["dense_vecs"][0]
# numpy → list[float] (asyncpg pgvector 텍스트 캐스트 호환). tolist() 있으면 사용.
dense = dense_vec.tolist() if hasattr(dense_vec, "tolist") else list(dense_vec)

View file

@ -12,7 +12,9 @@ from fastapi import Response
from starlette.requests import Request
from unittest.mock import AsyncMock, patch
from . import auth_sessions
from .config import Settings, settings
from .deps import Principal, Role
from .routes import auth as auth_routes
from .saml import inflate_redirect_request
@ -94,10 +96,18 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
auth_routes._oauth_states.clear()
auth_routes._saml_states.clear()
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._inactive_emails.clear()
async def asyncTearDown(self) -> None:
auth_routes._oauth_states.clear()
auth_routes._saml_states.clear()
auth_sessions._sessions.clear()
auth_sessions._users.clear()
auth_sessions._email_index.clear()
auth_sessions._inactive_emails.clear()
async def test_auth_config_reports_google_and_saml_provider_status(self) -> None:
with patched_settings(
@ -134,6 +144,60 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(config.dev_login_enabled)
async def test_learner_can_accept_and_withdraw_practice_consent(self) -> None:
with patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")):
_, user = await auth_sessions.create_session(
email="learner@hs.ac.kr",
display_name="Learner",
role="learner",
external_id="dev:learner@hs.ac.kr",
)
principal = Principal(
user_id=user.user_id,
role=Role.LEARNER,
cohort_ids=user.cohort_ids,
email=user.email,
display_name=user.display_name,
consent_at=user.consent_at,
)
accepted = await auth_routes.accept_consent(
auth_routes.ConsentRequest(accepted=True),
principal,
)
self.assertIsNotNone(accepted.consent_at)
self.assertEqual(principal.consent_at, accepted.consent_at)
self.assertTrue(await auth_sessions.user_has_consent(user.user_id))
withdrawn = await auth_routes.withdraw_consent(principal)
self.assertIsNone(withdrawn.consent_at)
self.assertIsNone(principal.consent_at)
self.assertFalse(await auth_sessions.user_has_consent(user.user_id))
async def test_consent_rejects_non_learner_and_unaccepted_body(self) -> None:
teacher = Principal(
user_id="00000000-0000-0000-0000-000000000501",
role=Role.TEACHER,
email="teacher@hs.ac.kr",
display_name="Teacher",
)
learner = Principal(
user_id="00000000-0000-0000-0000-000000000502",
role=Role.LEARNER,
email="learner@hs.ac.kr",
display_name="Learner",
)
with self.assertRaises(auth_routes.HTTPException) as teacher_error:
await auth_routes.accept_consent(auth_routes.ConsentRequest(), teacher)
with self.assertRaises(auth_routes.HTTPException) as learner_error:
await auth_routes.accept_consent(auth_routes.ConsentRequest(accepted=False), learner)
self.assertEqual(teacher_error.exception.status_code, 403)
self.assertEqual(learner_error.exception.status_code, 400)
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
request = _request(
[

View file

@ -22,6 +22,7 @@ def _principal(role: Role = Role.LEARNER) -> Principal:
cohort_ids=["cohort-a"] if role == Role.TEACHER else [],
email=f"{role.value}@example.test",
display_name=role.value.title(),
consent_at=1.0 if role == Role.LEARNER else None,
)

View file

@ -24,6 +24,7 @@ def _principal() -> Principal:
cohort_ids=[],
email="turn-test@hs.ac.kr",
display_name="Turn Test",
consent_at=1.0,
)
@ -374,6 +375,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.recall_summary, recall.recall_summary)
self.assertIs(sessions._RECALL_CACHE[response.session_id], recall)
async def test_start_session_requires_learner_consent_before_catalog_lookup(self) -> None:
principal = _principal()
principal.consent_at = None
with patch.object(
sessions,
"get_catalog_persona",
AsyncMock(side_effect=AssertionError("consent gate must run before catalog lookup")),
) as get_persona:
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.start_session(
sessions.SessionStartRequest(persona_code=persona_service.P1.code),
principal,
)
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(caught.exception.detail, "consent_required")
get_persona.assert_not_awaited()
async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None:
class FakeStreamEngine:
engine_mode = "claude_cli"

View file

@ -203,6 +203,9 @@ test.describe("layout visual gate @single-run", () => {
display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
},
});
await page.request.post("/api/auth/consent", {
data: { accepted: true },
});
// Seed dense history: one active + two ended sessions.
const persona = await fetchAvailablePersona(page);
const made: string[] = [];

View file

@ -267,6 +267,7 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
template === "none" ? 0 : template.split(" ").filter(Boolean).length;
const root = document.querySelector<HTMLElement>(".vg-set");
const forms = document.querySelector<HTMLElement>(".vg-set__forms");
const railTitle = document.querySelector<HTMLElement>(".vg-set__rail-title");
const railCard = document.querySelector<HTMLElement>(".vg-set__rail-card");
const nav = document.querySelector<HTMLElement>(".vg-set__nav");
const account = document.querySelector<HTMLElement>("#set-account");
@ -274,12 +275,24 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
const notify = document.querySelector<HTMLElement>("#set-notify");
const voice = document.querySelector<HTMLElement>("#set-voice");
if (!root || !forms || !railCard || !nav || !account || !appearance || !notify || !voice) {
if (
!root ||
!forms ||
!railTitle ||
!railCard ||
!nav ||
!account ||
!appearance ||
!notify ||
!voice
) {
return { ready: false };
}
const rootStyle = getComputedStyle(root);
const rootColumns = countColumns(getComputedStyle(root).gridTemplateColumns);
const formColumns = countColumns(getComputedStyle(forms).gridTemplateColumns);
const railTitleRect = railTitle.getBoundingClientRect();
const railCardRect = railCard.getBoundingClientRect();
const navRect = nav.getBoundingClientRect();
const accountStyle = getComputedStyle(account);
@ -290,20 +303,17 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
if (layoutMode === "desktop") {
return {
ready: true,
rootColumns,
rootIsTwoColumnGrid: rootStyle.display === "grid" && rootColumns === 2,
formColumns,
railCardVisible: railCardRect.height > 24,
shortPanelsShareRow:
Math.abs(appearanceRect.top - notifyRect.top) <= 4 &&
appearanceRect.left < notifyRect.left,
voiceBelowShortPanels:
voiceRect.top > appearanceRect.top && voiceRect.top > notifyRect.top,
railTitleVisible: railTitleRect.height > 28,
railCardHidden: railCardRect.height === 0,
voiceBeforeNotify: voiceRect.top < notifyRect.top,
};
}
return {
ready: true,
rootColumns,
rootSingleColumn: rootStyle.display !== "grid",
formColumns,
railCardHidden: railCardRect.height === 0,
navSingleLine: navRect.height <= 54,
@ -315,15 +325,15 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
mode === "desktop"
? {
ready: true,
rootColumns: 2,
formColumns: 2,
railCardVisible: true,
shortPanelsShareRow: true,
voiceBelowShortPanels: true,
rootIsTwoColumnGrid: true,
formColumns: 1,
railTitleVisible: true,
railCardHidden: true,
voiceBeforeNotify: true,
}
: {
ready: true,
rootColumns: 1,
rootSingleColumn: true,
formColumns: 1,
railCardHidden: true,
navSingleLine: true,

View file

@ -69,6 +69,10 @@ export async function signInAsLearner(page: Page) {
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const consent = await page.request.post("/api/auth/consent", {
data: { accepted: true },
});
expect(consent.ok(), await consent.text()).toBeTruthy();
}
export async function signInAsTeacher(page: Page) {

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 MiB

View file

@ -275,6 +275,49 @@ body[data-role="admin"] .vg-nav__foot {
border-top-color: rgba(255, 255, 255, 0.1);
}
body[data-role="instructor"] .vg-shell__body {
--nav-cur: 108px;
background-image: linear-gradient(rgba(38, 88, 83, 0.16), rgba(38, 88, 83, 0.16));
}
body[data-role="instructor"] .vg-nav {
background:
linear-gradient(180deg, rgba(32, 107, 99, 0.98), rgba(22, 83, 79, 0.98)),
var(--asset-warm-elements) center / cover no-repeat;
color: #eef4f2;
padding: var(--sp-5) 10px;
}
body[data-role="instructor"] .vg-nav__label,
body[data-role="instructor"] .vg-nav__ethic {
color: rgba(238, 244, 242, 0.62);
}
body[data-role="instructor"] .vg-nav__item {
flex-direction: column;
justify-content: center;
gap: 6px;
min-height: 64px;
padding: 9px 8px;
color: rgba(238, 244, 242, 0.82);
font-size: 12px;
text-align: center;
}
body[data-role="instructor"] .vg-nav__item:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
}
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
color: rgba(218, 242, 238, 0.78);
}
body[data-role="instructor"] .vg-nav__item.is-active {
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
}
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
color: #ffffff;
}
body[data-role="instructor"] .vg-nav__foot {
border-top-color: rgba(255, 255, 255, 0.13);
}
/* ── 메인 콘텐츠 ── */
.vg-main {
min-width: 0; /* 그리드 자식 overflow 방지 */
@ -298,6 +341,9 @@ body[data-role="admin"] .vg-nav__foot {
/* 그리드·구분선이 함께 축소 폭을 따른다 */
--nav-cur: var(--nav-w-collapsed);
}
body[data-role="instructor"] .vg-shell__body {
--nav-cur: var(--nav-w-collapsed);
}
.vg-shell__body--bare {
grid-template-columns: 1fr;
}
@ -368,6 +414,36 @@ body[data-role="admin"] .vg-nav__foot {
.vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7);
}
body[data-role="instructor"] .vg-topbar {
background: #1d7169;
border-bottom-color: rgba(255, 255, 255, 0.12);
}
body[data-role="instructor"] .vg-topbar__wm,
body[data-role="instructor"] .vg-topbar__role,
body[data-role="instructor"] .vg-topbar__uname,
body[data-role="instructor"] .vg-iconbtn {
color: #eef4f2;
}
body[data-role="instructor"] .vg-topbar__wm .v,
body[data-role="instructor"] .vg-topbar__mark {
color: #eef4f2;
}
body[data-role="instructor"] .vg-topbar__brand svg {
color: #eef4f2;
}
body[data-role="instructor"] .vg-topbar__role {
border-left-color: rgba(255, 255, 255, 0.18);
}
body[data-role="instructor"] .vg-topbar__user {
background: rgba(255, 255, 255, 0.14);
}
body[data-role="instructor"] .vg-topbar__avatar {
background: rgba(255, 255, 255, 0.18);
color: #ffffff;
}
body[data-role="instructor"] .vg-nav {
border-bottom-color: rgba(255, 255, 255, 0.14);
}
.vg-main--bleed {
padding: 0;
}

View file

@ -156,6 +156,30 @@ export interface paths {
patch?: never;
trace?: never;
};
"/auth/consent": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Accept Consent
* @description Record the current learner's practice-session consent receipt.
*/
post: operations["accept_consent_auth_consent_post"];
/**
* Withdraw Consent
* @description Withdraw practice-session consent until the learner accepts again.
*/
delete: operations["withdraw_consent_auth_consent_delete"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/auth/dev-login": {
parameters: {
query?: never;
@ -1072,6 +1096,19 @@ export interface components {
/** Rationale */
rationale?: string | null;
};
/** ConsentRequest */
ConsentRequest: {
/**
* Accepted
* @default true
*/
accepted: boolean;
};
/** ConsentResponse */
ConsentResponse: {
/** Consent At */
consent_at?: number | null;
};
/** CrisisResourceResponse */
CrisisResourceResponse: {
/** Message */
@ -1294,6 +1331,8 @@ export interface components {
MeResponse: {
/** Cohort Ids */
cohort_ids: string[];
/** Consent At */
consent_at?: number | null;
/** Display Name */
display_name: string;
/** Email */
@ -2586,6 +2625,74 @@ export interface operations {
};
};
};
accept_consent_auth_consent_post: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody: {
content: {
"application/json": components["schemas"]["ConsentRequest"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConsentResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
withdraw_consent_auth_consent_delete: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: {
"__Host-vignette_sid"?: string | null;
vignette_sid?: string | null;
};
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["ConsentResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
dev_login_auth_dev_login_post: {
parameters: {
query?: never;

View file

@ -161,6 +161,11 @@ export interface MeResponse {
display_name: string;
role: string; // "learner" | "teacher" | "admin"
cohort_ids: string[];
consent_at: number | null;
}
export interface ConsentResponse {
consent_at: number | null;
}
export interface AuthConfigResponse {
@ -179,6 +184,8 @@ export interface AuthConfigResponse {
export const authApi = {
config: () => api.get<AuthConfigResponse>("/auth/config"),
acceptConsent: () => api.post<ConsentResponse>("/auth/consent", { accepted: true }),
withdrawConsent: () => api.del<ConsentResponse>("/auth/consent"),
};
export type SessionStage = "라포" | "탐색" | "개입" | "정리";

View file

@ -7,7 +7,7 @@ import {
useState,
type ReactNode,
} from "react";
import { api, type MeResponse } from "./api";
import { api, authApi, type MeResponse } from "./api";
export type Role = "learner" | "teacher" | "admin";
export type DesignRole = "learner" | "instructor" | "admin";
@ -18,6 +18,7 @@ export interface AuthUser {
name: string;
role: Role;
cohortIds: string[];
consentAt: number | null;
}
export interface AuthContextValue {
@ -26,6 +27,8 @@ export interface AuthContextValue {
loading: boolean;
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
logout: () => Promise<void>;
acceptConsent: () => Promise<void>;
withdrawConsent: () => Promise<void>;
}
export function designRoleOf(role: Role): DesignRole {
@ -75,6 +78,7 @@ function userFromMe(me: MeResponse): AuthUser {
name: me.display_name || me.email || me.user_id,
role: (me.role as Role) ?? "learner",
cohortIds: me.cohort_ids ?? [],
consentAt: me.consent_at ?? null,
};
}
@ -124,9 +128,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}
}, []);
const acceptConsent = useCallback<AuthContextValue["acceptConsent"]>(async () => {
const response = await authApi.acceptConsent();
setUser((current) =>
current ? { ...current, consentAt: response.consent_at ?? null } : current,
);
}, []);
const withdrawConsent = useCallback<AuthContextValue["withdrawConsent"]>(async () => {
await authApi.withdrawConsent();
setUser((current) => (current ? { ...current, consentAt: null } : current));
}, []);
const value = useMemo<AuthContextValue>(
() => ({ user, role: user?.role ?? null, loading, login, logout }),
[user, loading, login, logout],
() => ({ user, role: user?.role ?? null, loading, login, logout, acceptConsent, withdrawConsent }),
[user, loading, login, logout, acceptConsent, withdrawConsent],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;

View file

@ -848,7 +848,7 @@ const ADMIN_CSS = `
content:"";
position:absolute;
z-index:-1;
inset:-80px -180px auto auto;
inset:-80px 0 auto auto;
width:min(560px,52vw);
height:420px;
background:var(--asset-warm-elements) center / cover no-repeat;

View file

@ -438,13 +438,13 @@ const LH_CSS = `
gap:clamp(12px,1.6vw,20px);
padding:clamp(14px,2vw,28px);
background:var(--bg-app);
overflow:visible;
overflow:hidden;
}
.lh-root::before{
content:"";
position:absolute;
z-index:-1;
inset:-120px -220px auto auto;
inset:-120px 0 auto auto;
width:min(720px,58vw);
height:520px;
background:var(--asset-warm-elements) center / cover no-repeat;
@ -1044,6 +1044,11 @@ const LH_CSS = `
.lh-head{
align-items:flex-start;
}
.lh-workspace{
display:flex;
flex-direction:column;
gap:10px;
}
.lh-list-pane{
padding:12px;
}

View file

@ -370,8 +370,8 @@ const LOGIN_CSS = `
gap:var(--sp-7);
padding:var(--sp-7);
background:
linear-gradient(115deg,rgba(14,22,20,.94),rgba(30,39,36,.84) 54%,rgba(30,39,36,.68)),
var(--asset-warm-elements) center / cover no-repeat;
linear-gradient(115deg,rgba(14,22,20,.86),rgba(30,39,36,.7) 48%,rgba(30,39,36,.48)),
var(--asset-login-room) center / cover no-repeat;
color:#edf4f2;
overflow:hidden;
}

View file

@ -290,23 +290,23 @@ export default function Professor() {
const kpis = useMemo(
() => [
{
label: "학습자",
value: dashboard?.total_learners ?? 0,
hint: "담당 코호트",
icon: "users" as const,
label: "검토 대기",
value: dashboard?.pending_reviews.length ?? 0,
hint: "종료 회기",
icon: "review" as const,
},
{
label: "진행 중",
value: dashboard?.active_sessions ?? 0,
hint: "열린 회기",
icon: "play" as const,
},
{
label: "종료",
value: dashboard?.ended_sessions ?? 0,
hint: "저장 완료",
label: "페르소나 검수",
value: personaReviews.length,
hint: "승인 대기",
icon: "check" as const,
},
{
label: "최근 세션",
value: dashboard?.recent_sessions.length ?? 0,
hint: "종료 · 진행",
icon: "play" as const,
},
{
label: "위기 알림",
value: dashboard?.safety_alerts.length ?? 0,
@ -314,13 +314,19 @@ export default function Professor() {
icon: "alert" as const,
},
{
label: "리뷰 대기",
value: dashboard?.pending_reviews.length ?? 0,
hint: "교수자 확인",
icon: "review" as const,
label: "전체 학생",
value: dashboard?.total_learners ?? 0,
hint: "담당 코호트",
icon: "users" as const,
},
{
label: "활성 세션",
value: dashboard?.active_sessions ?? 0,
hint: "진행 중",
icon: "play" as const,
},
],
[dashboard],
[dashboard, personaReviews.length],
);
const pendingCount = dashboard?.pending_reviews.length ?? 0;
const hasPending = pendingCount > 0;
@ -430,7 +436,7 @@ export default function Professor() {
<section className="pf-workspace">
<div className="pf-queue-stack">
<section className="pf-section">
<section className="pf-section pf-section--draft">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -494,7 +500,7 @@ export default function Professor() {
</Card>
</section>
<section className="pf-section">
<section className="pf-section pf-section--persona">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -591,7 +597,7 @@ export default function Professor() {
</Card>
</section>
<section className="pf-section">
<section className="pf-section pf-section--safety">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -618,7 +624,7 @@ export default function Professor() {
</Card>
</section>
<section className="pf-section">
<section className="pf-section pf-section--pending">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
@ -831,11 +837,11 @@ function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
const PF_CSS = `
.pf-root{
max-width:var(--maxw);
max-width:1280px;
margin:0 auto;
display:flex;
flex-direction:column;
gap:18px;
gap:16px;
}
.pf-head{
display:flex;
@ -853,19 +859,20 @@ const PF_CSS = `
}
.pf-head p{
margin:6px 0 0;
max-width:680px;
max-width:760px;
color:var(--text-body);
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-signal-strip{
order:1;
display:grid;
grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr);
gap:12px;
grid-template-columns:1fr;
gap:0;
min-width:0;
}
.pf-triage{
display:grid;
display:none;
grid-template-columns:auto minmax(0,1fr) auto;
align-items:center;
gap:12px;
@ -933,7 +940,7 @@ const PF_CSS = `
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
grid-template-columns:repeat(6,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
@ -943,13 +950,13 @@ const PF_CSS = `
.pf-kpi{
position:relative;
min-width:0;
padding:11px 12px;
padding:14px 16px;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:4px 10px;
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
@ -971,7 +978,7 @@ const PF_CSS = `
display:block;
color:var(--text-strong);
font-family:var(--font-num);
font-size:23px;
font-size:24px;
line-height:1;
}
.pf-kpi small{
@ -980,8 +987,9 @@ const PF_CSS = `
line-height:1.35;
}
.pf-workspace{
order:2;
display:grid;
grid-template-columns:minmax(330px,380px) minmax(0,1fr);
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
@ -1073,8 +1081,13 @@ const PF_CSS = `
min-width:84px;
}
.pf-section--growth{
order:3;
min-width:0;
}
.pf-section--pending{order:1;}
.pf-section--persona{order:2;}
.pf-section--safety{order:3;}
.pf-section--draft{order:4;}
.pf-growth-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
@ -1576,11 +1589,11 @@ const PF_CSS = `
.pf-session__meta{justify-content:flex-start;}
}
@media (max-width:520px){
.pf-kpis{grid-template-columns:1fr;}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi,
.pf-kpi:nth-child(even),
.pf-kpi + .pf-kpi{border-left:0;}
.pf-kpi + .pf-kpi{border-top:1px solid var(--hair);}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-persona__actions{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));

View file

@ -35,6 +35,7 @@ import {
type SessionDetailResponse,
type SessionStage,
} from "../lib/api";
import { useAuth } from "../lib/auth";
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
import { SlideToEnd } from "./session/SlideToEnd";
import "./session/session.css";
@ -377,6 +378,7 @@ function expressionForSession({
export default function Session() {
const { sessionId: routeId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const { user, acceptConsent } = useAuth();
const routeParam = routeId ?? "";
const routeIsSessionId = useMemo(() => looksLikeSessionId(routeParam), [routeParam]);
@ -400,6 +402,8 @@ export default function Session() {
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
const [starting, setStarting] = useState(false);
const [startError, setStartError] = useState<string | null>(null);
const [consentChecked, setConsentChecked] = useState(false);
const [consentBusy, setConsentBusy] = useState(false);
// ── 회기/대화 상태 ──
const [stage, setStage] = useState<SessionStage>("라포");
@ -656,13 +660,34 @@ export default function Session() {
);
}
});
} catch {
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
} catch (err) {
if (err instanceof ApiError && err.detail === "consent_required") {
setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다.");
} else {
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
}
} finally {
setStarting(false);
}
}, [personaCode, personaSummary, pushSignal]);
const handleAcceptConsent = useCallback(async () => {
if (!consentChecked) {
setStartError("동의 항목을 확인해야 회기를 시작할 수 있습니다.");
return;
}
setConsentBusy(true);
setStartError(null);
try {
await acceptConsent();
pushSignal("pos", "동의 확인");
} catch {
setStartError("동의 상태를 저장하지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
} finally {
setConsentBusy(false);
}
}, [acceptConsent, consentChecked, pushSignal]);
const appendServerClientReply = useCallback(
(replyText: string | null, at = elapsed) => {
if (!replyText) {
@ -1256,10 +1281,13 @@ export default function Session() {
sending;
const elapsedLabel = formatElapsed(elapsed);
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
const canStartSession = personaLoadState === "ready" && personaIsUsable;
const consentRequired = user?.role === "learner" && user.consentAt == null;
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
const prestartTitle = canStartSession
? `${clientName}님과의 회기를 시작할까요?`
: personaLoadState === "ready" && personaSummary
: consentRequired
? "동의 확인 후 회기를 시작할 수 있습니다."
: personaLoadState === "ready" && personaSummary
? "이 내담자는 현재 연습에 사용할 수 없습니다."
: "연습 대상 정보를 확인하고 있습니다.";
const personaStatusMessage =
@ -1371,6 +1399,29 @@ export default function Session() {
<p className="sx-prestart__note">{personaStatusMessage}</p>
) : null}
{startError ? <p className="sx-prestart__err">{startError}</p> : null}
{consentRequired ? (
<div className="sx-consent">
<label>
<input
type="checkbox"
checked={consentChecked}
onChange={(event) => setConsentChecked(event.currentTarget.checked)}
/>
<span>
, AI ,
.
</span>
</label>
<Button
size="sm"
onClick={handleAcceptConsent}
disabled={!consentChecked || consentBusy}
leading={<Icon name="check" size={15} />}
>
{consentBusy ? "저장 중" : "동의 저장"}
</Button>
</div>
) : null}
<div className="sx-prestart__actions">
<Button
size="lg"

View file

@ -8,7 +8,6 @@ import {
Input,
Kicker,
Panel,
SectionHead,
type IconName,
} from "../components/ui";
import { roleLabel, useAuth } from "../lib/auth";
@ -158,6 +157,13 @@ export default function Settings() {
const affiliationDirtyRef = useRef(false);
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
useEffect(() => {
document.body.setAttribute("data-page", "settings");
return () => {
document.body.removeAttribute("data-page");
};
}, []);
const flashSaved = (key: string) => {
setSavedKey(key);
if (savedTimer.current) window.clearTimeout(savedTimer.current);
@ -365,23 +371,7 @@ export default function Settings() {
const engineServiceStatus = engineService?.status ?? "degraded";
return (
<AppShell contextLabel="설정">
<div className="vg-set__mast">
<SectionHead
kicker="설정"
title="계정과 학습 환경을 관리합니다"
desc="계정 표시 정보, 음성, 알림, 운영 설정을 한 곳에서 관리합니다."
/>
<div className="vg-set__mast-meta" aria-label="설정 상태">
<span className="vg-set__pill">{roleLabel(role)}</span>
{isAdmin ? (
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
AI {healthStatusLabel(engineService?.status)}
</span>
) : null}
</div>
</div>
<AppShell contextLabel="설정" hideNav hideTopbar bleed>
{error ? (
<div className="vg-set__callout vg-set__callout--warn" role="alert">
<span className="vg-set__callout-ico">
@ -393,6 +383,16 @@ export default function Settings() {
<div className="vg-set" aria-busy={loading}>
<aside className="vg-set__rail">
<div className="vg-set__rail-title">
<span className="vg-set__rail-mark" aria-hidden="true">
<Icon name="settings" size={24} strokeWidth={2} />
</span>
<div>
<h1></h1>
<p> </p>
</div>
</div>
<div className="vg-set__rail-card" aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true">
{initials}
@ -424,6 +424,15 @@ export default function Settings() {
</button>
))}
</nav>
<div className="vg-set__rail-status" aria-label="설정 상태">
<span className="vg-set__pill">{roleLabel(role)}</span>
{isAdmin ? (
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
AI {healthStatusLabel(engineService?.status)}
</span>
) : null}
</div>
</aside>
<div className="vg-set__forms">

View file

@ -12,7 +12,7 @@
content: "";
position: absolute;
z-index: -1;
inset: -90px -170px auto auto;
inset: -90px 0 auto auto;
width: min(620px, 56vw);
height: 440px;
background: var(--asset-warm-elements) center / cover no-repeat;
@ -128,16 +128,15 @@
.sr-cols {
min-width: 0;
display: grid;
grid-template-columns: minmax(0, 1.34fr) minmax(320px, 0.66fr);
grid-template-columns: minmax(230px, 0.62fr) minmax(0, 1.22fr) minmax(280px, 0.68fr);
grid-template-areas:
"overview rubric"
"chart rubric"
"flow good"
"transcript growth"
"transcript worksheet"
"transcript feedback"
"transcript session";
gap: var(--sp-5);
"overview chart rubric"
"overview flow good"
"transcript transcript growth"
"transcript transcript feedback"
"worksheet worksheet worksheet"
"session session session";
gap: var(--sp-4);
align-items: start;
margin-top: 0;
}
@ -150,6 +149,9 @@
min-width: 0;
display: grid;
gap: var(--sp-4);
align-self: start;
position: sticky;
top: calc(var(--topbar-h) + var(--sp-4));
}
.sr-feedback {
grid-area: feedback;
@ -181,6 +183,7 @@
.sr-card {
min-width: 0;
box-shadow: var(--shadow-sm);
border-radius: var(--radius);
}
.sr-card--side {
align-self: start;
@ -199,12 +202,19 @@
letter-spacing: 0;
line-height: 1.45;
}
.sr-overview .sr-summary {
font-size: clamp(18px, 1.26vw, 21px);
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 12;
-webkit-box-orient: vertical;
}
.sr-summary .sr-hl {
color: var(--accent-deep);
}
.sr-readiness {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: 1fr;
gap: 10px;
padding-top: var(--sp-4);
border-top: 1px solid var(--hair);
@ -229,11 +239,13 @@
font-weight: 650;
}
.sr-actions {
display: flex;
align-items: center;
flex-wrap: wrap;
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-3);
}
.sr-actions .vg-btn {
width: 100%;
}
.sr-empty {
max-width: none;
@ -703,6 +715,10 @@
display: grid;
gap: var(--sp-4);
margin-top: var(--sp-4);
max-height: 620px;
overflow: auto;
padding-right: 4px;
scrollbar-gutter: stable;
}
.sr-ws-section {
min-width: 0;
@ -961,11 +977,27 @@
"chart flow"
"rubric rubric"
"good growth"
"worksheet worksheet"
"feedback feedback"
"transcript transcript"
"feedback feedback"
"worksheet worksheet"
"session session";
}
.sr-overview {
position: static;
}
.sr-readiness {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.sr-actions {
display: flex;
flex-wrap: wrap;
}
.sr-actions .vg-btn {
width: auto;
}
.sr-worksheet {
max-height: 520px;
}
}
@media (max-width: 780px) {
@ -992,9 +1024,9 @@
"rubric"
"good"
"growth"
"worksheet"
"feedback"
"transcript"
"feedback"
"worksheet"
"session";
gap: var(--sp-4);
}
@ -1002,6 +1034,9 @@
font-size: 20px;
line-height: 1.45;
}
.sr-overview .sr-summary {
-webkit-line-clamp: 8;
}
.sr-readiness {
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
@ -1012,6 +1047,9 @@
.sr-actions .vg-btn {
flex: 1 1 150px;
}
.sr-worksheet {
max-height: 460px;
}
.sr-chart__plot {
height: 156px;
}
@ -1049,6 +1087,9 @@
.sr-summary {
font-size: 18px;
}
.sr-overview .sr-summary {
-webkit-line-clamp: 7;
}
.sr-readiness {
grid-template-columns: 1fr;
}

View file

@ -1505,6 +1505,33 @@
font-size: var(--fs-sm);
color: var(--crit-text);
}
.sx-consent {
width: min(100%, 520px);
display: grid;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid rgba(132, 203, 179, 0.28);
border-radius: var(--radius-sm);
background: rgba(30, 86, 77, 0.22);
}
.sx-consent label {
display: flex;
align-items: flex-start;
gap: var(--sp-3);
color: rgba(238, 244, 242, 0.84);
font-size: var(--fs-sm);
line-height: 1.5;
}
.sx-consent input {
width: 17px;
height: 17px;
margin-top: 2px;
accent-color: #6fc6a8;
flex: 0 0 auto;
}
.sx-consent .vg-btn {
justify-self: start;
}
.sx-prestart__actions {
display: flex;
align-items: center;

View file

@ -6,6 +6,45 @@
===================================================================== */
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */
body[data-page="settings"] .vg-shell {
background:
linear-gradient(135deg, rgba(251, 250, 248, 0.96), rgba(244, 242, 238, 0.92)),
var(--asset-warm-elements) center / cover no-repeat;
}
body[data-page="settings"] .vg-topbar {
background: rgba(251, 250, 248, 0.94);
border-bottom-color: var(--hair);
color: var(--text-strong);
}
body[data-page="settings"] .vg-topbar__wm,
body[data-page="settings"] .vg-topbar__wm .v,
body[data-page="settings"] .vg-topbar__role,
body[data-page="settings"] .vg-topbar__uname,
body[data-page="settings"] .vg-iconbtn,
body[data-page="settings"] .vg-topbar__mark {
color: var(--text-strong);
}
body[data-page="settings"] .vg-topbar__brand svg {
color: var(--accent);
}
body[data-page="settings"] .vg-topbar__role {
border-left-color: var(--hair);
}
body[data-page="settings"] .vg-topbar__user {
background: var(--bg-surface-2);
}
body[data-page="settings"] .vg-topbar__avatar {
background: var(--accent-tint);
color: var(--accent-deep);
}
body[data-page="settings"] .vg-iconbtn:hover {
background: var(--bg-surface-2);
color: var(--text-strong);
}
body[data-page="settings"] .vg-main {
padding: var(--sp-6) var(--sp-5) var(--sp-8);
}
.vg-set {
display: grid;
grid-template-columns: 204px minmax(0, 780px);
@ -1175,3 +1214,326 @@
align-items: flex-start;
}
}
/* Image-aligned workspace: generated settings concept -> production controls. */
body[data-page="settings"] .vg-shell {
min-height: 100dvh;
background:
radial-gradient(circle at 8% 0%, rgba(222, 238, 234, 0.62), transparent 34%),
linear-gradient(135deg, rgba(252, 250, 247, 0.97), rgba(244, 242, 237, 0.94)),
var(--asset-warm-elements) center / cover no-repeat;
}
body[data-page="settings"] .vg-main {
padding: 0;
}
body[data-page="settings"] .vg-main__inner {
max-width: none;
}
body[data-page="settings"] .vg-set__callout {
max-width: 1120px;
margin: var(--sp-5) auto 0;
}
body[data-page="settings"] .vg-set {
grid-template-columns: 244px minmax(0, 820px);
justify-content: center;
align-items: start;
gap: 28px;
max-width: 1168px;
min-height: 100dvh;
padding: 30px 24px;
margin: 0 auto;
}
body[data-page="settings"] .vg-set__rail {
position: sticky;
top: 24px;
min-height: calc(100dvh - 60px);
padding: 24px 16px 18px;
border: 1px solid rgba(38, 101, 92, 0.14);
border-radius: var(--radius);
background:
linear-gradient(180deg, rgba(238, 248, 245, 0.92), rgba(248, 250, 248, 0.72)),
rgba(255, 255, 255, 0.82);
box-shadow: 0 24px 58px rgba(37, 45, 42, 0.08);
}
body[data-page="settings"] .vg-set__rail-title {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
padding: 2px 6px 20px;
}
body[data-page="settings"] .vg-set__rail-mark {
width: 34px;
height: 34px;
display: grid;
place-items: center;
flex: none;
color: var(--accent-deep);
}
body[data-page="settings"] .vg-set__rail-title h1 {
margin: 0;
color: var(--text-strong);
font-size: 24px;
line-height: 1.15;
letter-spacing: 0;
}
body[data-page="settings"] .vg-set__rail-title p {
margin: 3px 0 0;
color: var(--text-muted);
font-size: var(--fs-xs);
line-height: 1.35;
}
body[data-page="settings"] .vg-set__rail-card {
display: none;
}
body[data-page="settings"] .vg-set__nav {
position: static;
padding: 0;
gap: 10px;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
}
body[data-page="settings"] .vg-set__nav-kicker {
display: none;
}
body[data-page="settings"] .vg-set__nav-item {
min-height: 49px;
padding: 0 14px;
border-radius: var(--radius);
color: #1f5f58;
font-size: 15px;
font-weight: 650;
}
body[data-page="settings"] .vg-set__nav-item:hover {
background: rgba(24, 121, 103, 0.09);
color: #124d47;
}
body[data-page="settings"] .vg-set__nav-item.is-active {
background: linear-gradient(135deg, #187967, #106759);
color: #ffffff;
box-shadow: 0 12px 24px rgba(16, 103, 89, 0.22);
}
body[data-page="settings"] .vg-set__nav-item.is-active svg {
color: #ffffff;
}
body[data-page="settings"] .vg-set__rail-status {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: auto;
padding: 18px 4px 0;
}
body[data-page="settings"] .vg-set__forms {
display: grid;
grid-template-columns: 1fr;
gap: 14px;
max-width: 820px;
}
body[data-page="settings"] #set-account {
order: 1;
}
body[data-page="settings"] #set-engine {
order: 2;
}
body[data-page="settings"] #set-appearance {
order: 3;
}
body[data-page="settings"] #set-voice {
order: 4;
}
body[data-page="settings"] #set-notify {
order: 5;
}
body[data-page="settings"] .vg-set__group {
padding: 18px;
border: 1px solid rgba(37, 45, 42, 0.12);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.93);
box-shadow: 0 18px 38px rgba(38, 45, 43, 0.07);
}
body[data-page="settings"] .vg-set__group-head {
align-items: flex-start;
margin-bottom: 14px;
}
body[data-page="settings"] .vg-set__group-title {
font-size: 18px;
font-weight: 750;
}
body[data-page="settings"] .vg-set__group-desc {
font-size: 12px;
color: var(--text-muted);
}
body[data-page="settings"] .vg-set__profile {
margin-bottom: 12px;
padding: 13px;
background: rgba(244, 241, 236, 0.78);
}
body[data-page="settings"] .vg-set__avatar {
background: linear-gradient(135deg, #1d8774, #0f6559);
}
body[data-page="settings"] .vg-set__field-grid--account,
body[data-page="settings"] .vg-set__field-grid--engine {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
body[data-page="settings"] .vg-set__field--wide {
grid-column: 1 / -1;
}
body[data-page="settings"] .vg-set__field,
body[data-page="settings"] .vg-set__control-block,
body[data-page="settings"] .vg-set__opt,
body[data-page="settings"] .vg-set__range-row {
border-color: rgba(37, 45, 42, 0.12);
background: rgba(255, 255, 255, 0.86);
}
body[data-page="settings"] .vg-set__engine-grid {
grid-template-columns: 1fr;
gap: 10px;
}
body[data-page="settings"] .vg-set__seg {
grid-template-columns: repeat(4, minmax(0, 1fr));
background: rgba(244, 241, 236, 0.82);
}
body[data-page="settings"] .vg-set__seg-btn.is-active {
background: #187967;
color: #ffffff;
}
body[data-page="settings"] .vg-set__ops {
width: min(300px, 100%);
min-height: 54px;
}
body[data-page="settings"] .vg-set__voicelist {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
body[data-page="settings"] .vg-set__voice {
min-height: 72px;
}
body[data-page="settings"] .vg-set__foot {
margin-top: 12px;
padding-top: 12px;
}
body[data-page="settings"] .vg-set__pill {
background: rgba(255, 255, 255, 0.68);
}
@media (max-width: 980px) {
body[data-page="settings"] .vg-set {
grid-template-columns: 86px minmax(0, 1fr);
gap: 18px;
max-width: none;
}
body[data-page="settings"] .vg-set__rail {
padding: 18px 10px;
}
body[data-page="settings"] .vg-set__rail-title {
justify-content: center;
padding: 0 0 16px;
}
body[data-page="settings"] .vg-set__rail-title div,
body[data-page="settings"] .vg-set__nav-item span,
body[data-page="settings"] .vg-set__rail-status {
display: none;
}
body[data-page="settings"] .vg-set__nav-item {
justify-content: center;
min-height: 48px;
padding: 0;
}
body[data-page="settings"] .vg-set__forms {
max-width: none;
}
}
@media (max-width: 720px) {
body[data-page="settings"] .vg-set__callout {
margin: 12px 12px 0;
}
body[data-page="settings"] .vg-set {
display: block;
min-height: 100dvh;
padding: 0 16px 28px;
}
body[data-page="settings"] .vg-set__rail {
position: sticky;
top: 0;
z-index: 20;
min-height: auto;
margin: 0 -16px 12px;
padding: 13px 16px 10px;
border: 0;
border-bottom: 1px solid rgba(37, 45, 42, 0.1);
border-radius: 0;
background: rgba(252, 250, 247, 0.96);
box-shadow: 0 12px 26px rgba(37, 45, 42, 0.08);
}
body[data-page="settings"] .vg-set__rail-title {
justify-content: center;
gap: 8px;
padding: 0 0 10px;
}
body[data-page="settings"] .vg-set__rail-title div {
display: block;
}
body[data-page="settings"] .vg-set__rail-title h1 {
font-size: 16px;
}
body[data-page="settings"] .vg-set__rail-title p {
display: none;
}
body[data-page="settings"] .vg-set__rail-mark {
width: 24px;
height: 24px;
}
body[data-page="settings"] .vg-set__nav {
display: flex;
flex-direction: row;
flex-wrap: nowrap;
justify-content: center;
gap: 4px;
overflow-x: auto;
padding-bottom: 2px;
scrollbar-width: thin;
}
body[data-page="settings"] .vg-set__nav-item {
flex: 0 0 auto;
min-height: 34px;
gap: 5px;
padding: 0 7px;
font-size: 11px;
white-space: nowrap;
}
body[data-page="settings"] .vg-set__nav-item svg {
width: 14px;
height: 14px;
}
body[data-page="settings"] .vg-set__nav-item span {
display: inline;
}
body[data-page="settings"] .vg-set__forms {
gap: 10px;
}
body[data-page="settings"] .vg-set__group {
padding: 13px;
}
body[data-page="settings"] .vg-set__field-grid--account,
body[data-page="settings"] .vg-set__field-grid--engine,
body[data-page="settings"] .vg-set__seg,
body[data-page="settings"] .vg-set__voicelist {
grid-template-columns: 1fr;
}
body[data-page="settings"] .vg-set__field--wide {
grid-column: auto;
}
body[data-page="settings"] .vg-set__group-head--split {
flex-direction: column;
}
body[data-page="settings"] .vg-set__ops {
width: 100%;
}
body[data-page="settings"] .vg-set__voice {
min-height: 0;
}
}

View file

@ -113,6 +113,7 @@
/* ── 래스터 디자인 요소 ── */
--asset-warm-elements: url("/design-elements/clinical-paper-ambient.png");
--asset-login-room: url("/design-elements/login-counseling-room.png");
/* ── 모션 §3.9 ── */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);