diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py index 663b692..0a61f96 100644 --- a/apps/api/app/auth_sessions.py +++ b/apps/api/app/auth_sessions.py @@ -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: diff --git a/apps/api/app/deps.py b/apps/api/app/deps.py index 6b45a4c..45040be 100644 --- a/apps/api/app/deps.py +++ b/apps/api/app/deps.py @@ -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, ) diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py index af630a3..75ae102 100644 --- a/apps/api/app/routes/auth.py +++ b/apps/api/app/routes/auth.py @@ -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.""" diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index 8cd0cc0..ea87e95 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -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) diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index 58585f4..730d1a3 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -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: diff --git a/apps/api/app/services/rag.py b/apps/api/app/services/rag.py index 7845b02..6081504 100644 --- a/apps/api/app/services/rag.py +++ b/apps/api/app/services/rag.py @@ -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) diff --git a/apps/api/app/test_auth_providers.py b/apps/api/app/test_auth_providers.py index ba75890..e2e3f39 100644 --- a/apps/api/app/test_auth_providers.py +++ b/apps/api/app/test_auth_providers.py @@ -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( [ diff --git a/apps/api/app/test_persona_review.py b/apps/api/app/test_persona_review.py index f8a83d8..1bc5de9 100644 --- a/apps/api/app/test_persona_review.py +++ b/apps/api/app/test_persona_review.py @@ -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, ) diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py index 93214d3..73293dc 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -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" diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index bd232ee..1442504 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -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[] = []; diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index 99eef77..b6f434a 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -267,6 +267,7 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi template === "none" ? 0 : template.split(" ").filter(Boolean).length; const root = document.querySelector(".vg-set"); const forms = document.querySelector(".vg-set__forms"); + const railTitle = document.querySelector(".vg-set__rail-title"); const railCard = document.querySelector(".vg-set__rail-card"); const nav = document.querySelector(".vg-set__nav"); const account = document.querySelector("#set-account"); @@ -274,12 +275,24 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi const notify = document.querySelector("#set-notify"); const voice = document.querySelector("#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, diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts index 551adc7..4dda417 100644 --- a/apps/web/e2e/support.ts +++ b/apps/web/e2e/support.ts @@ -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) { diff --git a/apps/web/public/design-elements/login-counseling-room.png b/apps/web/public/design-elements/login-counseling-room.png new file mode 100644 index 0000000..a7c6b2c Binary files /dev/null and b/apps/web/public/design-elements/login-counseling-room.png differ diff --git a/apps/web/public/design-elements/warm-visual-elements.png b/apps/web/public/design-elements/warm-visual-elements.png deleted file mode 100644 index b0275ea..0000000 Binary files a/apps/web/public/design-elements/warm-visual-elements.png and /dev/null differ diff --git a/apps/web/src/components/shell/shell.css b/apps/web/src/components/shell/shell.css index 6464201..972c8b2 100644 --- a/apps/web/src/components/shell/shell.css +++ b/apps/web/src/components/shell/shell.css @@ -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; } diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts index fa72c25..08cfc94 100644 --- a/apps/web/src/lib/api.gen.ts +++ b/apps/web/src/lib/api.gen.ts @@ -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; diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts index 0d46d15..bcb28a5 100644 --- a/apps/web/src/lib/api.ts +++ b/apps/web/src/lib/api.ts @@ -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("/auth/config"), + acceptConsent: () => api.post("/auth/consent", { accepted: true }), + withdrawConsent: () => api.del("/auth/consent"), }; export type SessionStage = "라포" | "탐색" | "개입" | "정리"; diff --git a/apps/web/src/lib/auth.tsx b/apps/web/src/lib/auth.tsx index b5a13ef..8640da4 100644 --- a/apps/web/src/lib/auth.tsx +++ b/apps/web/src/lib/auth.tsx @@ -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; logout: () => Promise; + acceptConsent: () => Promise; + withdrawConsent: () => Promise; } 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(async () => { + const response = await authApi.acceptConsent(); + setUser((current) => + current ? { ...current, consentAt: response.consent_at ?? null } : current, + ); + }, []); + + const withdrawConsent = useCallback(async () => { + await authApi.withdrawConsent(); + setUser((current) => (current ? { ...current, consentAt: null } : current)); + }, []); + const value = useMemo( - () => ({ 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 {children}; diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx index fbfa5c4..296ade0 100644 --- a/apps/web/src/pages/Admin.tsx +++ b/apps/web/src/pages/Admin.tsx @@ -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; diff --git a/apps/web/src/pages/LearnerHome.tsx b/apps/web/src/pages/LearnerHome.tsx index a0e2d2e..b08320e 100644 --- a/apps/web/src/pages/LearnerHome.tsx +++ b/apps/web/src/pages/LearnerHome.tsx @@ -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; } diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx index ff26a47..5a53b3d 100644 --- a/apps/web/src/pages/Login.tsx +++ b/apps/web/src/pages/Login.tsx @@ -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; } diff --git a/apps/web/src/pages/Professor.tsx b/apps/web/src/pages/Professor.tsx index 2083ea9..eda2107 100644 --- a/apps/web/src/pages/Professor.tsx +++ b/apps/web/src/pages/Professor.tsx @@ -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() {
-
+
페르소나 저작 @@ -494,7 +500,7 @@ export default function Professor() {
-
+
페르소나 검수 @@ -591,7 +597,7 @@ export default function Professor() {
-
+
위기 알림 @@ -618,7 +624,7 @@ export default function Professor() {
-
+
검토 대기 @@ -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)); diff --git a/apps/web/src/pages/Session.tsx b/apps/web/src/pages/Session.tsx index fe9086f..784002f 100644 --- a/apps/web/src/pages/Session.tsx +++ b/apps/web/src/pages/Session.tsx @@ -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(null); const [starting, setStarting] = useState(false); const [startError, setStartError] = useState(null); + const [consentChecked, setConsentChecked] = useState(false); + const [consentBusy, setConsentBusy] = useState(false); // ── 회기/대화 상태 ── const [stage, setStage] = useState("라포"); @@ -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() {

{personaStatusMessage}

) : null} {startError ?

{startError}

: null} + {consentRequired ? ( +
+ + +
+ ) : null}
))} + +
+ {roleLabel(role)} + {isAdmin ? ( + + AI {healthStatusLabel(engineService?.status)} + + ) : null} +
diff --git a/apps/web/src/pages/session-review/session-review.css b/apps/web/src/pages/session-review/session-review.css index 3566945..87e62fd 100644 --- a/apps/web/src/pages/session-review/session-review.css +++ b/apps/web/src/pages/session-review/session-review.css @@ -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; } diff --git a/apps/web/src/pages/session/session.css b/apps/web/src/pages/session/session.css index b820982..5a2f59b 100644 --- a/apps/web/src/pages/session/session.css +++ b/apps/web/src/pages/session/session.css @@ -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; diff --git a/apps/web/src/pages/settings/settings.css b/apps/web/src/pages/settings/settings.css index 27c426e..5444f70 100644 --- a/apps/web/src/pages/settings/settings.css +++ b/apps/web/src/pages/settings/settings.css @@ -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; + } +} diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css index dba102f..985d2e2 100644 --- a/apps/web/src/styles/tokens.css +++ b/apps/web/src/styles/tokens.css @@ -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); diff --git a/docs/HANDOFF.md b/docs/HANDOFF.md index 301c636..81d456c 100644 --- a/docs/HANDOFF.md +++ b/docs/HANDOFF.md @@ -1,6 +1,6 @@ # Vignette Handoff -> Updated: 2026-06-27 16:16 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. +> Updated: 2026-06-27 17:06 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다. ## 현재 상태 @@ -10,25 +10,26 @@ - 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099` - 공개 웹: `https://vignette.chanpaca.net` - 공개 API: `https://api-vignette.chanpaca.net` -- 앱 배포 소스 commit: `6bd91b0d` (`현재 작업 상태 저장`) -- 최신 Cloudflare Pages production deploy: `2a58ca49`, source `6bd91b0d` + dirty worktree, branch `main` +- 직전 기록된 앱 배포 소스 commit: `6bd91b0d` (`현재 작업 상태 저장`) +- 직전 기록된 Cloudflare Pages production deploy: `2a58ca49`, source `6bd91b0d` + dirty worktree, branch `main`. 최신 배포 ID는 `wrangler pages deploy` 실행 로그와 최종 작업 보고를 기준으로 확인한다. - Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr` -- 최신 백엔드 회귀: `python -m pytest app/ -q` → `122 passed` +- 최신 백엔드 회귀: `python -m pytest app/ -q` → `125 passed` - X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부. - C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점은 후속. - C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속. - H2 평가 정규화: 턴 평가의 대안발화도 `app.alternative_utterance`에 적재하고 리뷰 hydrate 시 `alternative_utterances`로 복원한다. - X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다. -- H4 LLM call audit: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. +- H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속. +- RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed. - M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속. - Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다. - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. - 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다. - 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다. -- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. +- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-27 17:00 KST 재확인: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true. - `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료. - 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다. -- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이며 dev-login까지 검증됐다. +- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 `scripts/start-tailscale-runtime.ps1` 재기동 후 `login` 200, `/api/health` dev/db/engine true, direct Google start → `local_oauth_unavailable`로 재검증됐다. - `scripts/dev-up.ps1 -NoGateway -NoWeb`는 이제 gateway/web stale 정리를 건너뛰고 지정 `-ApiPort`의 API만 재기동한다. 검증 당시 8000 API-only 재기동 후 8001/8010/5173/9099/20241 listener가 보존됐다. - Docker `vignette-dev-db`는 실행 중이고 DB는 accepting connections다. 단, 기존 컨테이너라 healthcheck가 없고 `POSTGRES_USER=vignette` 기반이다. `vignette_app` role은 NOBYPASSRLS로 존재하지만 현재 API startup DDL이 owner 권한을 요구하므로 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행한다. - 공개 런타임 재기동 스크립트: diff --git a/docs/dev_dashboard.html b/docs/dev_dashboard.html index 87e046a..4840c2e 100644 --- a/docs/dev_dashboard.html +++ b/docs/dev_dashboard.html @@ -450,6 +450,9 @@

2차 적용(2026-06-27): 생성된 관리자/설정/리뷰 시안 기준으로 admin 역할의 상단바·사이드바를 어두운 운영 콘솔 크롬으로 전환했고, 관리자 사용자 관리 행을 조밀한 운영 테이블 밀도로 낮췄다. 회기 리뷰는 헤더·인사이트 카드·피드백 스테이지에 따뜻한 래스터 질감과 낮은 채도 틴트를 적용했다.

3차 적용(2026-06-27): 상담 세션 시작/진행 화면을 시안 기준의 어두운 상담 스테이지로 재정렬했다. 시작 전 화면은 내담자 무대·브리핑·진행 초점 3영역으로 유지하고, 진행 중 화면은 어두운 캔버스 위에 중앙 스테이지와 자막/컨트롤을 배치한다.

4차 적용(2026-06-27): 교수 콘솔은 시안의 triage 콘솔 구조에 맞춰 KPI를 전폭 6칸으로 재배치하고, 종료 회기 검토 큐와 최근 회기 기록을 첫 화면 2열 핵심 영역으로 올렸다. 성장 추적·페르소나 저작·검수 보조 큐는 하단 보조 업무로 낮췄고, 교수자 사이드바는 좁은 teal rail로 전환했다.

+

5차 적용(2026-06-27): 회기 리뷰는 시안의 좌·중·우 작업대 구조에 맞춰 요약/액션 레일, 감정·흐름 중앙 영역, 우측 인사이트 패널로 재배치했다. 모바일에서는 트랜스크립트를 워크시트보다 먼저 노출하고, 사례개념화 워크시트는 제한 높이의 보조 패널로 낮춰 긴 문서처럼 보이지 않게 했다.

+

6차 적용(2026-06-27): 설정 화면은 글로벌 셸을 숨기고 설정 전용 좌측 레일을 주 내비로 승격했다. 계정, AI 운영, 테마, 음성, 알림 카드를 시안의 차분한 설정 워크스페이스 흐름에 맞춰 단일 컬럼으로 재배치했고, 모바일은 상단 고정 섹션 탭으로 모든 항목이 잘리지 않게 정리했다.

+

7차 적용(2026-06-27): 로그인 화면은 별도 생성한 래스터 상담실 배경(login-counseling-room.png)을 왼쪽 브랜드 패널에 적용해 시안의 실제 상담실 분위기를 반영했다. 학습자 홈은 모바일 grid 행 계산 때문에 생기던 리스트-상세 간 큰 공백을 제거하고, 회기 준비 보드가 한 흐름으로 이어지게 정리했다.

래스터만 사용이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.
기능 우선메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.
@@ -592,8 +595,8 @@
현재

POST/GET/PUT /personas/drafts를 추가해 teacher/admin이 페르소나 카드를 draft로 생성·조회·편집하고 review 상태로 올릴 수 있다. 교수 콘솔에는 JSON 초안 패널과 검수 큐의 편집 불러오기를 연결했다. 승인된 카드만 학습자 catalog에 노출되는 기존 경계는 유지.

검증

pytest app/test_persona_review.py -q 19 passed, pytest app/ -q 119 passed, npm run check:api-types, npm run typecheck, npm run build passed.

잔여

P4~P7 실제 임상 콘텐츠 적재, JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화는 후속. 임상팀이 제공하지 않은 페르소나 내용을 임의 생성하지 않는다.

- -
현재

정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 audit.llm_call_log에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 /turn smoke에서 audit.llm_call_log가 8→11로 3행 증가했다. 검증: 마스킹/감사 5 passed, 관련 회귀 27 passed, 전체 API 122 passed.

잔여

이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시), 미성년/guardian 동의 수집·게이트·철회. 공개 Google OAuth 실제 /turn proof는 별도 운영 게이트.

+ +
현재

정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 audit.llm_call_log에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 /turn smoke에서 audit.llm_call_log가 8→11로 3행 증가했다. 이번 패스에서 app_user.consent_at 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 125 passed, npm run check:api-types, npm run typecheck, npm run build.

잔여

이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 /turn proof는 별도 운영 게이트.

@@ -605,7 +608,7 @@
-

즉시 착수 가능(내부 코드, 외부 합의 불요)

  • C1/C2/C3/H2/H3은 1차 구조 완료. 후속은 저장형 워크시트, 임상 스크립트, CBT 체인, 2열 리뷰 UI, P4~P7 콘텐츠 적재처럼 데이터·정책·UI 고도화 영역이다.
  • H4(부분): 한국어 정규식 마스킹, metadata-only LLM call audit 경로, 로컬 live row proof는 완료. 남은 내부 후보는 이름/기관 NER와 동의 게이트 골격이다. X1 dry-run export 1차와 X2 비용 관측·예산 경고/evaluator 모델 override 1차는 완료, X2 semantic cache는 후속. M1/M2/M3 1차는 완료, provider 기반 한숨·울음 감지와 case_digest/pinned_fact 실적재, 운영 SAML 서명검증은 후속.
+

즉시 착수 가능(내부 코드, 외부 합의 불요)

  • C1/C2/C3/H2/H3은 1차 구조 완료. 후속은 저장형 워크시트, 임상 스크립트, CBT 체인, 2열 리뷰 UI, P4~P7 콘텐츠 적재처럼 데이터·정책·UI 고도화 영역이다.
  • H4(부분): 한국어 정규식 마스킹, metadata-only LLM call audit 경로, 로컬 live row proof, learner 동의 수락/철회/회기 시작 하드게이트 골격은 완료. 남은 내부 후보는 이름/기관 NER이고, guardian/legal 서명 동의 evidence는 외부 게이트다. X1 dry-run export 1차와 X2 비용 관측·예산 경고/evaluator 모델 override 1차는 완료, X2 semantic cache는 후속. M1/M2/M3 1차는 완료, provider 기반 한숨·울음 감지와 case_digest/pinned_fact 실적재, 운영 SAML 서명검증은 후속.

소유자 결정 / 외부(임상팀·기관) 의존

  • 임상팀 산출물: C1 워크시트 항목·루브릭, C3 CBT 콘텐츠·체인, C2 위기 스크립트·서약 문안, H2 골든셋 코딩.
  • 소유자 평가설계: H1 실험/통제군·3척도 문항·50분 흐름, κ/ICC·환각률 목표(doc4 미명시).
  • 기관(한신 IT): M3 SSO IdP·test tenant·claim 스키마·인증서, 거버넌스 증거.
  • 거버넌스: L1 스택 정합성 방향·저작권 등재 문서화·IP 협의.

판독 한계 (정직성)

  • 핵심 축어록 2단 표는 PDF 시각 판독이 1차 근거(txt 일부 누락), OCR 잔재 존재.
  • doc4 κ/ICC·환각률은 신청서 본문 미명시 — 계약 확정 지표로 단정 금지.
  • '현재상태'는 grep/코드 사실 기반. C1~C3·H2·H3은 직접 재확인(✓), 그 외는 착수 전 코드 1차 재확인 권고.
@@ -641,7 +644,7 @@
-
현재

임베더 BGE-M3 검증+버전핀(1.4.0 dtype버그→1.2.11/transformers 4.44.2). embed_query를 asyncio.to_thread로 offload. 턴 파이프라인 배선: retrieve_persona_memory→episodic recall, search_kb→kb_behavior_cues. KB seed 3청크 적재. rag.py latent 버그 3건 수정. GraphRAG는 2차 보류.

성능 회귀 수정

최초 RAG 배선이 회기 시작/턴에 임베더 로드(~7-13s)를 동기 awaitPOST /sessions가 막힘 → session-layout E2E 4건 타임아웃. 수정: 회상·KB 회수를 _warm_rag_caches 백그라운드 task로 옮기고 턴은 캐시만 읽음(비차단). 재검증: session-layout 4/4 + 핵심 E2E 27/27·11/11·아바타 4/4 통과.

시각 게이트

admin 1280 input "text-clipped-x" 1건은 폼 input 값 스크롤(설계상 정상) 오탐 — form 컨트롤을 text-clip 판정에서 제외 → 게이트 7/7(실 결함 검사 유지).

+
현재

임베더 BGE-M3 검증+버전핀(1.4.0 dtype버그→1.2.11/transformers 4.44.2). embed_query를 asyncio.to_thread로 offload. 턴 파이프라인 배선: retrieve_persona_memory→episodic recall, search_kb→kb_behavior_cues. KB seed 3청크 적재. rag.py latent 버그 3건 수정. GraphRAG는 2차 보류.

성능 회귀 수정

최초 RAG 배선이 회기 시작/턴에 임베더 로드(~7-13s)를 동기 awaitPOST /sessions가 막힘 → session-layout E2E 4건 타임아웃. 수정: 회상·KB 회수를 _warm_rag_caches 백그라운드 task로 옮기고 턴은 캐시만 읽음(비차단). 이번 동의 게이트 E2E 재검증 중 여러 세션 warm task가 BGE-M3를 동시에 지연 로드해 API health/dev-login timeout이 반복된 문제는 embedder load/encode RLock과 warm semaphore 1개로 안정화했다. 재검증: session-layout 4/4 + 핵심 E2E 27/27·11/11·아바타 4/4, 최신 layout/session 조합 11/11 통과.

시각 게이트

admin 1280 input "text-clipped-x" 1건은 폼 input 값 스크롤(설계상 정상) 오탐 — form 컨트롤을 text-clip 판정에서 제외 → 게이트 7/7(실 결함 검사 유지).

@@ -793,6 +796,7 @@ Turn evaluation SSOPpytest app/test_session_turn_persistence.py app/test_evaluation_persistence.py app/test_voice_ws.py20 tests OK; submit/stream/voice fast-loop evaluation normalized mapping + evaluator-only raw RLS + turn insert id 확인 Postgres RLS/audit smoke checkerpython scripts\check-postgres-rls-audit.py --helppy_compile/help/safe default OK; live DSN run still required for proof OAuth/BFF cookie securityapp.test_auth_providers20 tests OK; PKCE/state, cookie-bound signed state restart recovery, state cookie mismatch reject, opaque HttpOnly/Secure cookie, no browser tokens, SAML AuthnRequest/dev-only fixture ACS, dev/Tailnet OAuth guard, public-origin dev-login closed + H4 consent gatepytest app/test_auth_providers.py app/test_session_turn_persistence.py app/test_persona_review.py app/test_voice_ws.py -q64 passed; learner consent accept/withdraw, non-learner/unaccepted reject, no-consent session start 403 consent_required, voice dev persona consent guard, frontend generated API contract updated Persona review workflowapp.test_persona_review19 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approve/reject audit, learner 403 Voice WS contractapp.test_voice_ws5 tests OK; auth guard, audio_start/chunks/audio_end, text_turn, ping, max audio cap Voice preset/EOTapp.test_voice_service11 tests OK; TTS payload, preset fallback, P1 sample TTS, EOT readiness @@ -819,7 +823,7 @@ Public loginauth.spec.ts --grep public login1 passed Public runtime scriptsstart/watch/install-public-runtime*.ps1parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live api-vnet.18ka.net; add future domains explicitly with -AdditionalPublicHealthUrls. Public API healthhttps://api-vignette.chanpaca.net/healthprod, db true, engine true - Public/local/Tailnet login recoveryhttps://vignette.chanpaca.net/login / http://localhost:5173/login / https://alpaca-home.taile93291.ts.net/loginpublic login 200, local/Tailnet login 200, public Google auth redirect 302 with HttpOnly state cookie, cookie-bound signed OAuth state survives API restart(fake code → token_exchange_failed, not invalid_state), provider callback error → oauth=access_denied, OAuth callback 실패는 api.public.err.log에 reason/status만 비밀값 없이 남김, 로그인 화면은 reason code 표시, local/Tailnet Google direct → local_oauth_unavailable, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed + Public/local/Tailnet login recoveryhttps://vignette.chanpaca.net/login / http://localhost:5173/login / https://alpaca-home.taile93291.ts.net/login2026-06-27 재확인: 모바일 502 시각의 cloudflared 로그는 127.0.0.1:8001 origin refused였고 현재 public login 200, public API health prod/db/engine true. Tailnet 403은 stale Vite allowedHosts 문제였고 start-tailscale-runtime.ps1 재기동 후 login 200 + `/api/health` dev/db/engine true. public Google auth redirect 302 with HttpOnly state cookie, local/Tailnet Google direct → local_oauth_unavailable. Local 5175 loginPLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.tsdesktop/mobile passed Learner/readiness E2Elearner.spec.ts + readiness.spec.ts desktop/mobile14 passed Learner screenshotslearn-empty-desktop.png / learn-empty-mobile-compact.pngdocument overflow 0, empty history visible diff --git a/docs/guides/source-docs-and-gaps.md b/docs/guides/source-docs-and-gaps.md index 6174e9d..71df6a4 100644 --- a/docs/guides/source-docs-and-gaps.md +++ b/docs/guides/source-docs-and-gaps.md @@ -62,7 +62,7 @@ | **H1** | 계약 평가 KPI(자기효능감·기술숙련도·수련만족도 사전사후) 수집·집계 전무 | `자기효능감/사전사후/수련만족/실험통제군` grep 0건. Phase3 KPI도 report shape만, 계산 코드 0줄. (분석) | 3척도 pre-post 폼·실험/통제군 배정·자동누적 대시보드·추이 시각화·검정 계산 코드. (κ/ICC·환각률은 doc4 미명시 → 평가설계 확정.) | doc4(20명 실험/통제군·단회기 50분·3척도 pre-post) | | **H2** | 턴별 회기 리뷰 fast-loop 1차 가동·골든셋/2열 UI 잔여 | `make_eval_hook`이 submit/voice 생성 경로에 주입되고, stream은 `_evaluate_stream_turn`으로 fast-loop 평가를 붙인다. 결과는 `feedback_scores`, `alternative_utterance` 등 정규화 테이블에 적재·hydrate된다. | 회기리뷰 UI 좌(축어록 타임라인+비언어)/우(기법·적절성·대안반응·이론) 2열 고도화, 원천 축어록 few-shot 골든셋 적재. | doc2·doc5(골드 포맷) | | **H3** | 임상팀 콘텐츠 입력 경로(페르소나 저작 CRUD) 부재 + P4~P7 미적재 | personas 라우트에 검수 승인/반려만, draft 생성·편집 API 없음. `persona_repository.py`는 in-code `SEED_PERSONAS`(P1~P3)만 materialize, 외부 JSON 미로드 ✓. | 페르소나 저작 CRUD(draft→review) + P4~P7 적재, 루브릭·이론 콘텐츠를 임상팀 편집 가능 데이터로 외부화. | doc3(R&R)·doc4(페르소나=전문가 산출물) | -| **H4** | PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여 | Presidio `language='en'` 고정이라 이름/기관명은 NER 보강이 필요하지만, 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `consent_at` 컬럼만 있고 동의 수집/게이트/철회 엔드포인트는 아직 없다. | 한국어 이름/기관 NER 추가, 미성년/guardian 동의 수집·게이트·철회. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) | +| **H4** | PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여 | Presidio `language='en'` 고정이라 이름/기관명은 NER 보강이 필요하지만, 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user.consent_at` 기반 학습자 동의 수락/철회 엔드포인트와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 프론트 세션 시작 전 동의 UI와 E2E 동의 seed를 연결했다. | 한국어 이름/기관 NER 추가, 미성년/guardian 및 법무 검토가 필요한 서명 동의서/개인정보 고지 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) | ### Medium+ (6) — M1~M3, X1~X2, L1 @@ -90,7 +90,7 @@ - **C3 1차 완료**: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI. - **H2 1차 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋. - **H3**: 페르소나 저작 CRUD(draft→review) + P4~P7 로드 경로. -- **H4(부분)·X1·X2**: 마스킹 한국어 정규식 보강과 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, dry-run JSONL export·PII scan·IAA 계산 1차는 완료했다. `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고와 evaluator fast/deep 모델 override 1차는 완료했고 semantic cache·장기 한도 정책은 후속. M1/M2도 1차 구현 완료, provider 기반 비언어 감지와 case_digest/pinned_fact 실적재는 후속. +- **H4(부분)·X1·X2**: 마스킹 한국어 정규식 보강, 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속이다. dry-run JSONL export·PII scan·IAA 계산 1차도 완료했고 `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고와 evaluator fast/deep 모델 override 1차는 완료했고 semantic cache·장기 한도 정책은 후속. M1/M2도 1차 구현 완료, provider 기반 비언어 감지와 case_digest/pinned_fact 실적재는 후속. ### B. 소유자 결정 / 외부(임상팀·기관) 의존 diff --git a/docs/ops/backlog-2026-06-26.md b/docs/ops/backlog-2026-06-26.md index 1ffab34..898e690 100644 --- a/docs/ops/backlog-2026-06-26.md +++ b/docs/ops/backlog-2026-06-26.md @@ -75,5 +75,7 @@ - M3 인증 claim 1차: Google/SAML/dev-login이 `AUTH_EMAIL_COHORT_MAP`·`AUTH_DOMAIN_COHORT_MAP` 및 SAML cohort claim을 `cohort_ids`로 전달하고, DB `app_user.external_id`는 provider subject(`google:`/`saml:`/`dev:`) 기반으로 저장한다. 운영 SAML 서명검증, 기관 claim schema/test tenant, deprovisioning audit은 후속. **검증: `pytest app/test_auth_providers.py -q` 22 passed, `pytest app/ -q` 119 passed.** - X2 예산 경고/저비용 평가 라우팅 1차: `ADMIN_USAGE_BUDGET_USD` 설정값을 기준으로 `GET /admin/usage`가 `budget.status=disabled|ok|warn|exceeded`, 사용률, 잔여 예산을 반환하고 관리자 콘솔이 예산 상태 배너를 표시한다. 80% 이상 warn, 100% 이상 exceeded. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL` 설정 시 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달하고, 비워두면 기존 gateway default 라우팅을 유지한다. semantic cache·장기 한도 정책은 후속. **검증: `pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q` 23 passed, `pytest app/ -q` 122 passed, `npm run check:api-types`, `npm run typecheck`.** - H4 LLM call audit 1차: 상담 생성(generate/stream)·fast-loop 평가·deep-loop 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않고 감사 실패는 상담 루프를 막지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다(session `2460d56c-a9cb-4a40-a175-9575d510a5e9`). 공개 Google OAuth 실제 `/turn` proof는 별도 B2 항목에 남긴다. **검증: `pytest app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py -q` 27 passed, `pytest app/ -q` 119 passed.** -- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. **검증: `https://vignette.chanpaca.net/login` 200 + `index-BzQmv-jU.js`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, local/Tailnet Google direct는 각 origin의 `/login?oauth=local_oauth_unavailable`, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.** +- H4 동의 게이트 1차: `app_user.consent_at`를 런타임 테이블 readiness와 `SessionUser`/`Principal`에 포함하고, learner 전용 `POST /auth/consent`·`DELETE /auth/consent`를 추가했다. 동의가 없는 learner는 `POST /sessions`와 voice dev persona 시작이 `consent_required`로 막히고, 프론트 세션 시작 전 동의 체크 UI가 먼저 저장하도록 안내한다. E2E dev-login helper는 세션 테스트 전에 동의를 seed한다. 한국어 이름/기관 NER, guardian/legal 서명 동의서 evidence, 공개 Google OAuth 실제 `/turn` proof는 후속. **검증: `pytest app/ -q` 125 passed, `pytest app/test_auth_providers.py app/test_session_turn_persistence.py app/test_persona_review.py app/test_voice_ws.py -q` 64 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, local `/sessions` no-consent 403 + `/auth/consent` 200.** +- RAG warm 동시성 안정화: layout/session E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 여러 번 지연 로드되며 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py`에서 embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py`의 `_warm_rag_caches`를 semaphore 1개로 제한해 warm task가 상담 요청 경로를 막지 않도록 했다. **검증: `pytest app/ -q` 125 passed, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts --project=chromium-single-run --project=chromium-desktop --workers=1` 11 passed.** +- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. 2026-06-27 모바일 502는 cloudflared 로그의 `127.0.0.1:8001` origin refused와 일치했다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. **검증: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, local/Tailnet Google direct는 각 origin의 `/login?oauth=local_oauth_unavailable`, `https://alpaca-home.taile93291.ts.net/login` 200 + `/api/health` dev/db/engine true, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.** - Docker dev DB 안전장치: `scripts/dev-up.ps1`가 `vignette-dev-db` healthcheck/pg_isready/role safety를 점검하고, 새 컨테이너 생성 시 `vignette_owner`와 앱 role을 분리해 healthcheck를 붙인다. `infra\.env` compose 필수값 누락도 사전 경고한다. `scripts/dev-down.ps1`은 기본 DB 보존, `-Db` 지정 시 컨테이너 중지로 정리했다. **검증: 현재 Docker DB accepting connections, `vignette_app:false:false`, 기존 컨테이너 healthcheck 없음. 추가 확인 결과 기존 DB는 `POSTGRES_USER=vignette` 기반이고 API startup DDL이 owner 권한을 요구해 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행해야 한다.** diff --git a/docs/ops/source-docs-gap-analysis-2026-06-26.md b/docs/ops/source-docs-gap-analysis-2026-06-26.md index b4f964b..20c53ec 100644 --- a/docs/ops/source-docs-gap-analysis-2026-06-26.md +++ b/docs/ops/source-docs-gap-analysis-2026-06-26.md @@ -76,8 +76,8 @@ ### [high] H4. PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여 - **근거**: doc1/2/5(실명·날짜·장소·금액·미성년·자살시도 다수), doc4(IRB·개인정보) -- **현재상태**(재검증): Presidio language='en' 고정으로 이름/기관명은 NER 보강이 필요하다. 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐고, 상담 생성(generate/stream)·fast/deep 평가 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. consent_at 컬럼만, 동의 수집/게이트/철회 엔드포인트 전무. -- **권고**: 한국어 이름/기관 NER 추가, 미성년/guardian 동의 수집·하드게이트·철회. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. +- **현재상태**(재검증): Presidio language='en' 고정으로 이름/기관명은 NER 보강이 필요하다. 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐고, 상담 생성(generate/stream)·fast/deep 평가 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user.consent_at` 기반 학습자 동의 수락/철회 엔드포인트와 회기 시작·voice dev persona 시작 하드게이트를 추가했다. 프론트 세션 시작 전 동의 체크 UI와 E2E dev-login 동의 seed도 연결했다. +- **권고**: 한국어 이름/기관 NER 추가, 미성년/guardian 및 법무 검토가 필요한 서명 동의서/개인정보 고지 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. ### [medium] M1. 비언어/준언어 임상 이벤트 캡처·태깅 부재 - **근거**: doc1/2/5(침묵 초·한숨·울음·떨림을 1급 단서) @@ -115,7 +115,7 @@ - C3 1차 완료: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI. - H2 1차 완료: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋. - H3: 페르소나 저작 CRUD + P4~P7 로드 경로. -- H4(부분): 한국어 이름/기관 NER, 동의 게이트. LLM call audit 적재와 로컬 live row proof는 완료. +- H4(부분): 한국어 이름/기관 NER, guardian/legal 서명 동의 evidence. LLM call audit 적재, 로컬 live row proof, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료. - X1: dry-run JSONL export·PII scan·IAA 계산 1차 완료. `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·budget alert와 evaluator 모델 override 1차는 완료했고 semantic cache·장기 한도 정책은 후속. M1/M2도 1차 구현 완료, provider 기반 비언어 감지와 case_digest/pinned_fact 실적재는 후속. ### B. 소유자 결정 / 외부(임상팀·기관) 의존 diff --git a/docs/ops/tailscale-vnet-runtime-2026-06-27.md b/docs/ops/tailscale-vnet-runtime-2026-06-27.md index 429f377..a0081d5 100644 --- a/docs/ops/tailscale-vnet-runtime-2026-06-27.md +++ b/docs/ops/tailscale-vnet-runtime-2026-06-27.md @@ -25,6 +25,7 @@ - gateway `127.0.0.1:9099` - Tailnet API `127.0.0.1:8010` - web `127.0.0.1:5173` +- 2026-06-27 17:00 KST 재확인: Tailnet `GET /login`이 `403 Blocked request. This host ... is not allowed`를 반환하던 원인은 Vite가 stale env로 떠서 `VITE_ALLOWED_HOSTS=alpaca-home.taile93291.ts.net`를 반영하지 못한 상태였다. `scripts/start-tailscale-runtime.ps1 -ApiPort 8010 -WebPort 5173 -SkipServeUpdate` 재기동 후 `GET /login` 200, `GET /api/health` dev/db/engine true, direct Google start 302 `local_oauth_unavailable`로 복구 확인. ## 공개 vnet.18ka.net 상태