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

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 display_name: str
role: str role: str
cohort_ids: list[str] cohort_ids: list[str]
consent_at: float | None
expires_at: float expires_at: float
@ -39,6 +40,7 @@ class ManagedUser:
role: str role: str
cohort_ids: list[str] cohort_ids: list[str]
affiliation: str affiliation: str
consent_at: float | None
created_at: float created_at: float
last_seen_at: float last_seen_at: float
@ -95,6 +97,17 @@ def _ts(value: datetime | None) -> float:
return (value or datetime.now(timezone.utc)).timestamp() 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]: def _cohort_ids(cohort: str | None) -> list[str]:
return [cohort] if cohort else [] return [cohort] if cohort else []
@ -111,9 +124,9 @@ async def _runtime_tables_ready(conn) -> bool:
SELECT 1 FROM information_schema.columns SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app' WHERE table_schema = 'app'
AND table_name = 'app_user' 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 GROUP BY table_schema, table_name
HAVING count(*) = 3 HAVING count(*) = 4
) AS has_user_columns, ) AS has_user_columns,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session, to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences, 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 ALTER TABLE app.app_user
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '', 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 last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
ADD COLUMN IF NOT EXISTS updated_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"], email=row["email"],
display_name=row["display_name"] or row["email"], display_name=row["display_name"] or row["email"],
role=_app_role(row["role"]), 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, affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
consent_at=_optional_ts(_row_value(row, "consent_at")),
created_at=_ts(row["created_at"]), created_at=_ts(row["created_at"]),
last_seen_at=_ts(row["last_seen_at"]), last_seen_at=_ts(row["last_seen_at"]),
) )
@ -403,6 +418,7 @@ def _memory_upsert_managed_user(
cohort_ids: list[str] | None = None, cohort_ids: list[str] | None = None,
user_id: str | None = None, user_id: str | None = None,
affiliation: str | None = None, affiliation: str | None = None,
consent_at: float | None = None,
reactivate: bool = False, reactivate: bool = False,
) -> ManagedUser: ) -> ManagedUser:
now = time.time() now = time.time()
@ -424,6 +440,7 @@ def _memory_upsert_managed_user(
if affiliation if affiliation
else (current.affiliation if current else DEFAULT_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, created_at=current.created_at if current else now,
last_seen_at=now, last_seen_at=now,
) )
@ -464,7 +481,7 @@ async def upsert_managed_user(
last_seen_at = now(), last_seen_at = now(),
updated_at = now() updated_at = now()
WHERE app.app_user.is_active OR $7 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_external_id,
normalized_email, normalized_email,
@ -485,6 +502,7 @@ async def upsert_managed_user(
cohort_ids=user.cohort_ids, cohort_ids=user.cohort_ids,
user_id=user.user_id, user_id=user.user_id,
affiliation=user.affiliation, affiliation=user.affiliation,
consent_at=user.consent_at,
reactivate=True, reactivate=True,
) )
return user return user
@ -509,7 +527,7 @@ async def get_managed_user(user_id: str) -> ManagedUser | None:
async with pool.acquire() as conn: async with pool.acquire() as conn:
row = await conn.fetchrow( 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 FROM app.app_user
WHERE user_id = $1::uuid AND is_active 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: async with pool.acquire() as conn:
rows = await conn.fetch( 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 FROM app.app_user
WHERE is_active WHERE is_active
ORDER BY last_seen_at DESC ORDER BY last_seen_at DESC
@ -563,7 +581,7 @@ async def update_managed_user(
updated_at = now(), updated_at = now(),
last_seen_at = now() last_seen_at = now()
WHERE user_id = $1::uuid AND is_active 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, user_id,
display_name.strip() if display_name is not None else None, 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, cohort_ids=next_user.cohort_ids,
user_id=next_user.user_id, user_id=next_user.user_id,
affiliation=next_user.affiliation, affiliation=next_user.affiliation,
consent_at=next_user.consent_at,
) )
for session in _sessions.values(): for session in _sessions.values():
if session.user_id == user_id: if session.user_id == user_id:
session.display_name = next_user.display_name session.display_name = next_user.display_name
session.role = next_user.role session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids) session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
return next_user return next_user
except Exception: except Exception:
require_runtime_fallback_allowed("managed user update") 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, 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, 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, affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
consent_at=current.consent_at,
created_at=current.created_at, created_at=current.created_at,
last_seen_at=time.time(), last_seen_at=time.time(),
) )
@ -614,9 +635,118 @@ async def update_managed_user(
session.display_name = next_user.display_name session.display_name = next_user.display_name
session.role = next_user.role session.role = next_user.role
session.cohort_ids = list(next_user.cohort_ids) session.cohort_ids = list(next_user.cohort_ids)
session.consent_at = next_user.consent_at
return next_user 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: async def deactivate_managed_user(user_id: str) -> bool:
"""Deactivate a managed user and revoke their active browser sessions.""" """Deactivate a managed user and revoke their active browser sessions."""
changed = False changed = False
@ -718,6 +848,7 @@ async def create_session(
display_name=managed.display_name, display_name=managed.display_name,
role=managed.role, role=managed.role,
cohort_ids=list(managed.cohort_ids), cohort_ids=list(managed.cohort_ids),
consent_at=managed.consent_at,
expires_at=expires_at, expires_at=expires_at,
) )
sid_hash = _sid_hash(raw_sid) sid_hash = _sid_hash(raw_sid)
@ -767,7 +898,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
u.email, u.email,
COALESCE(u.display_name, s.display_name, u.email) AS display_name, COALESCE(u.display_name, s.display_name, u.email) AS display_name,
u.role, u.role,
u.cohort u.cohort,
u.consent_at
FROM app.auth_session s FROM app.auth_session s
JOIN app.app_user u ON u.user_id = s.user_id JOIN app.app_user u ON u.user_id = s.user_id
WHERE s.sid_hash = $1 WHERE s.sid_hash = $1
@ -792,6 +924,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
display_name=row["display_name"], display_name=row["display_name"],
role=_app_role(row["role"]), role=_app_role(row["role"]),
cohort_ids=_cohort_ids(row["cohort"]), cohort_ids=_cohort_ids(row["cohort"]),
consent_at=_optional_ts(row["consent_at"]),
expires_at=_ts(row["expires_at"]), expires_at=_ts(row["expires_at"]),
) )
except Exception: except Exception:

View file

@ -35,12 +35,14 @@ class Principal:
cohort_ids: Optional[list[str]] = None, cohort_ids: Optional[list[str]] = None,
email: str = "", email: str = "",
display_name: str = "", display_name: str = "",
consent_at: float | None = None,
) -> None: ) -> None:
self.user_id = user_id self.user_id = user_id
self.role = role self.role = role
self.cohort_ids = cohort_ids or [] self.cohort_ids = cohort_ids or []
self.email = email self.email = email
self.display_name = display_name self.display_name = display_name
self.consent_at = consent_at
def get_settings_dep() -> Settings: def get_settings_dep() -> Settings:
@ -74,6 +76,7 @@ async def get_current_principal(
cohort_ids=session.cohort_ids, cohort_ids=session.cohort_ids,
email=session.email, email=session.email,
display_name=session.display_name, 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 fastapi.responses import RedirectResponse
from pydantic import BaseModel 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 ..config import settings
from ..deps import CurrentPrincipal, Principal, Role from ..deps import CurrentPrincipal, Principal, Role
from ..saml import ( from ..saml import (
@ -71,6 +78,15 @@ class MeResponse(BaseModel):
display_name: str display_name: str
role: str role: str
cohort_ids: list[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): class AuthProviderStatus(BaseModel):
@ -564,6 +580,7 @@ def _me_response(user: SessionUser | Principal) -> MeResponse:
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""), display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
role=user.role.value if isinstance(user.role, Role) else user.role, role=user.role.value if isinstance(user.role, Role) else user.role,
cohort_ids=user.cohort_ids, cohort_ids=user.cohort_ids,
consent_at=getattr(user, "consent_at", None),
) )
@ -933,6 +950,35 @@ async def logout(
return {"ok": True} 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) @router.get("/me", response_model=MeResponse)
async def me(principal: CurrentPrincipal) -> MeResponse: async def me(principal: CurrentPrincipal) -> MeResponse:
"""Return the current authenticated user. Unauthenticated requests are 401.""" """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 sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime from .. import db, session_persistence, turn_runtime
from ..auth_sessions import user_has_consent
from ..config import settings from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client from ..engine_client import EngineError, engine_client
@ -240,6 +241,7 @@ class SessionReviewResponse(BaseModel):
_RECALL_CACHE: dict[str, memory.RecallContext] = {} _RECALL_CACHE: dict[str, memory.RecallContext] = {}
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성). # 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
_KB_CUES_CACHE: dict[str, list[str]] = {} _KB_CUES_CACHE: dict[str, list[str]] = {}
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
_LEARNER_VISIBLE_AI_ROLE = "counselor" _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로 띄운다. BGE-M3 임베더 로드(~ ) 회기 시작/ 응답을 막지 않도록 create_task로 띄운다.
warm 완료 턴은 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 구간 비치명적. warm 완료 턴은 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 구간 비치명적.
""" """
try: async with _RAG_WARM_SEMAPHORE:
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card) try:
except Exception: _RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
pass except Exception:
try: pass
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card) try:
except Exception: _KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
pass except Exception:
pass
_PHASE_KEY_BY_LABEL = { _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") 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( async def _load_session_or_404(
session_id: str, session_id: str,
principal: Principal, principal: Principal,
@ -1149,6 +1160,7 @@ async def start_session(
) -> SessionStartResponse: ) -> SessionStartResponse:
"""Start a learner-owned practice session.""" """Start a learner-owned practice session."""
_ensure_learner(principal) _ensure_learner(principal)
await _ensure_practice_consent(principal)
try: try:
catalog_persona = await get_catalog_persona(body.persona_code) 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 starlette.websockets import WebSocketState
from .. import session_persistence, turn_runtime 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 ..config import settings
from ..deps import Principal, Role from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client 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, cohort_ids=session.cohort_ids,
email=session.email, email=session.email,
display_name=session.display_name, display_name=session.display_name,
consent_at=session.consent_at,
) )
@ -457,6 +458,8 @@ async def _bind_session(
persona_code = qp.get("persona_code") persona_code = qp.get("persona_code")
if not persona_code: if not persona_code:
return None, None, "session_id or persona_code query required", {} 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: try:
catalog_persona = await get_catalog_persona(persona_code) catalog_persona = await get_catalog_persona(persona_code)
except Exception: except Exception:

View file

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

View file

@ -22,6 +22,7 @@ def _principal(role: Role = Role.LEARNER) -> Principal:
cohort_ids=["cohort-a"] if role == Role.TEACHER else [], cohort_ids=["cohort-a"] if role == Role.TEACHER else [],
email=f"{role.value}@example.test", email=f"{role.value}@example.test",
display_name=role.value.title(), 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=[], cohort_ids=[],
email="turn-test@hs.ac.kr", email="turn-test@hs.ac.kr",
display_name="Turn Test", 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.assertEqual(response.recall_summary, recall.recall_summary)
self.assertIs(sessions._RECALL_CACHE[response.session_id], recall) 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: async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None:
class FakeStreamEngine: class FakeStreamEngine:
engine_mode = "claude_cli" engine_mode = "claude_cli"

View file

@ -203,6 +203,9 @@ test.describe("layout visual gate @single-run", () => {
display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정", display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
}, },
}); });
await page.request.post("/api/auth/consent", {
data: { accepted: true },
});
// Seed dense history: one active + two ended sessions. // Seed dense history: one active + two ended sessions.
const persona = await fetchAvailablePersona(page); const persona = await fetchAvailablePersona(page);
const made: string[] = []; 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; template === "none" ? 0 : template.split(" ").filter(Boolean).length;
const root = document.querySelector<HTMLElement>(".vg-set"); const root = document.querySelector<HTMLElement>(".vg-set");
const forms = document.querySelector<HTMLElement>(".vg-set__forms"); 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 railCard = document.querySelector<HTMLElement>(".vg-set__rail-card");
const nav = document.querySelector<HTMLElement>(".vg-set__nav"); const nav = document.querySelector<HTMLElement>(".vg-set__nav");
const account = document.querySelector<HTMLElement>("#set-account"); 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 notify = document.querySelector<HTMLElement>("#set-notify");
const voice = document.querySelector<HTMLElement>("#set-voice"); 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 }; return { ready: false };
} }
const rootStyle = getComputedStyle(root);
const rootColumns = countColumns(getComputedStyle(root).gridTemplateColumns); const rootColumns = countColumns(getComputedStyle(root).gridTemplateColumns);
const formColumns = countColumns(getComputedStyle(forms).gridTemplateColumns); const formColumns = countColumns(getComputedStyle(forms).gridTemplateColumns);
const railTitleRect = railTitle.getBoundingClientRect();
const railCardRect = railCard.getBoundingClientRect(); const railCardRect = railCard.getBoundingClientRect();
const navRect = nav.getBoundingClientRect(); const navRect = nav.getBoundingClientRect();
const accountStyle = getComputedStyle(account); const accountStyle = getComputedStyle(account);
@ -290,20 +303,17 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
if (layoutMode === "desktop") { if (layoutMode === "desktop") {
return { return {
ready: true, ready: true,
rootColumns, rootIsTwoColumnGrid: rootStyle.display === "grid" && rootColumns === 2,
formColumns, formColumns,
railCardVisible: railCardRect.height > 24, railTitleVisible: railTitleRect.height > 28,
shortPanelsShareRow: railCardHidden: railCardRect.height === 0,
Math.abs(appearanceRect.top - notifyRect.top) <= 4 && voiceBeforeNotify: voiceRect.top < notifyRect.top,
appearanceRect.left < notifyRect.left,
voiceBelowShortPanels:
voiceRect.top > appearanceRect.top && voiceRect.top > notifyRect.top,
}; };
} }
return { return {
ready: true, ready: true,
rootColumns, rootSingleColumn: rootStyle.display !== "grid",
formColumns, formColumns,
railCardHidden: railCardRect.height === 0, railCardHidden: railCardRect.height === 0,
navSingleLine: navRect.height <= 54, navSingleLine: navRect.height <= 54,
@ -315,15 +325,15 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
mode === "desktop" mode === "desktop"
? { ? {
ready: true, ready: true,
rootColumns: 2, rootIsTwoColumnGrid: true,
formColumns: 2, formColumns: 1,
railCardVisible: true, railTitleVisible: true,
shortPanelsShareRow: true, railCardHidden: true,
voiceBelowShortPanels: true, voiceBeforeNotify: true,
} }
: { : {
ready: true, ready: true,
rootColumns: 1, rootSingleColumn: true,
formColumns: 1, formColumns: 1,
railCardHidden: true, railCardHidden: true,
navSingleLine: true, navSingleLine: true,

View file

@ -69,6 +69,10 @@ export async function signInAsLearner(page: Page) {
}, },
}); });
expect(res.ok(), await res.text()).toBeTruthy(); 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) { 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); 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 { .vg-main {
min-width: 0; /* 그리드 자식 overflow 방지 */ min-width: 0; /* 그리드 자식 overflow 방지 */
@ -298,6 +341,9 @@ body[data-role="admin"] .vg-nav__foot {
/* 그리드·구분선이 함께 축소 폭을 따른다 */ /* 그리드·구분선이 함께 축소 폭을 따른다 */
--nav-cur: var(--nav-w-collapsed); --nav-cur: var(--nav-w-collapsed);
} }
body[data-role="instructor"] .vg-shell__body {
--nav-cur: var(--nav-w-collapsed);
}
.vg-shell__body--bare { .vg-shell__body--bare {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
@ -368,6 +414,36 @@ body[data-role="admin"] .vg-nav__foot {
.vg-main { .vg-main {
padding: var(--sp-5) var(--sp-4) var(--sp-7); 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 { .vg-main--bleed {
padding: 0; padding: 0;
} }

View file

@ -156,6 +156,30 @@ export interface paths {
patch?: never; patch?: never;
trace?: 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": { "/auth/dev-login": {
parameters: { parameters: {
query?: never; query?: never;
@ -1072,6 +1096,19 @@ export interface components {
/** Rationale */ /** Rationale */
rationale?: string | null; rationale?: string | null;
}; };
/** ConsentRequest */
ConsentRequest: {
/**
* Accepted
* @default true
*/
accepted: boolean;
};
/** ConsentResponse */
ConsentResponse: {
/** Consent At */
consent_at?: number | null;
};
/** CrisisResourceResponse */ /** CrisisResourceResponse */
CrisisResourceResponse: { CrisisResourceResponse: {
/** Message */ /** Message */
@ -1294,6 +1331,8 @@ export interface components {
MeResponse: { MeResponse: {
/** Cohort Ids */ /** Cohort Ids */
cohort_ids: string[]; cohort_ids: string[];
/** Consent At */
consent_at?: number | null;
/** Display Name */ /** Display Name */
display_name: string; display_name: string;
/** Email */ /** 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: { dev_login_auth_dev_login_post: {
parameters: { parameters: {
query?: never; query?: never;

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -8,7 +8,6 @@ import {
Input, Input,
Kicker, Kicker,
Panel, Panel,
SectionHead,
type IconName, type IconName,
} from "../components/ui"; } from "../components/ui";
import { roleLabel, useAuth } from "../lib/auth"; import { roleLabel, useAuth } from "../lib/auth";
@ -158,6 +157,13 @@ export default function Settings() {
const affiliationDirtyRef = useRef(false); const affiliationDirtyRef = useRef(false);
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null); const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
useEffect(() => {
document.body.setAttribute("data-page", "settings");
return () => {
document.body.removeAttribute("data-page");
};
}, []);
const flashSaved = (key: string) => { const flashSaved = (key: string) => {
setSavedKey(key); setSavedKey(key);
if (savedTimer.current) window.clearTimeout(savedTimer.current); if (savedTimer.current) window.clearTimeout(savedTimer.current);
@ -365,23 +371,7 @@ export default function Settings() {
const engineServiceStatus = engineService?.status ?? "degraded"; const engineServiceStatus = engineService?.status ?? "degraded";
return ( return (
<AppShell contextLabel="설정"> <AppShell contextLabel="설정" hideNav hideTopbar bleed>
<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>
{error ? ( {error ? (
<div className="vg-set__callout vg-set__callout--warn" role="alert"> <div className="vg-set__callout vg-set__callout--warn" role="alert">
<span className="vg-set__callout-ico"> <span className="vg-set__callout-ico">
@ -393,6 +383,16 @@ export default function Settings() {
<div className="vg-set" aria-busy={loading}> <div className="vg-set" aria-busy={loading}>
<aside className="vg-set__rail"> <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-card" aria-label="계정 요약">
<div className="vg-set__rail-avatar" aria-hidden="true"> <div className="vg-set__rail-avatar" aria-hidden="true">
{initials} {initials}
@ -424,6 +424,15 @@ export default function Settings() {
</button> </button>
))} ))}
</nav> </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> </aside>
<div className="vg-set__forms"> <div className="vg-set__forms">

View file

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

View file

@ -1505,6 +1505,33 @@
font-size: var(--fs-sm); font-size: var(--fs-sm);
color: var(--crit-text); 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 { .sx-prestart__actions {
display: flex; display: flex;
align-items: center; align-items: center;

View file

@ -6,6 +6,45 @@
===================================================================== */ ===================================================================== */
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */ /* ── 레이아웃: 좌측 섹션 내비(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 { .vg-set {
display: grid; display: grid;
grid-template-columns: 204px minmax(0, 780px); grid-template-columns: 204px minmax(0, 780px);
@ -1175,3 +1214,326 @@
align-items: flex-start; 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-warm-elements: url("/design-elements/clinical-paper-ambient.png");
--asset-login-room: url("/design-elements/login-counseling-room.png");
/* ── 모션 §3.9 ── */ /* ── 모션 §3.9 ── */
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1); --ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);

View file

@ -1,6 +1,6 @@
# Vignette Handoff # 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` - 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099`
- 공개 웹: `https://vignette.chanpaca.net` - 공개 웹: `https://vignette.chanpaca.net`
- 공개 API: `https://api-vignette.chanpaca.net` - 공개 API: `https://api-vignette.chanpaca.net`
- 앱 배포 소스 commit: `6bd91b0d` (`현재 작업 상태 저장`) - 직전 기록된 앱 배포 소스 commit: `6bd91b0d` (`현재 작업 상태 저장`)
- 최신 Cloudflare Pages production deploy: `2a58ca49`, source `6bd91b0d` + dirty worktree, branch `main` - 직전 기록된 Cloudflare Pages production deploy: `2a58ca49`, source `6bd91b0d` + dirty worktree, branch `main`. 최신 배포 ID는 `wrangler pages deploy` 실행 로그와 최종 작업 보고를 기준으로 확인한다.
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr` - 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 없으면 거부. - 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 추출/채점은 후속. - C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점은 후속.
- C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속. - C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속.
- H2 평가 정규화: 턴 평가의 대안발화도 `app.alternative_utterance`에 적재하고 리뷰 hydrate 시 `alternative_utterances`로 복원한다. - 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 라우팅을 유지한다. - 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은 후속. - 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가 필요하다. - Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다. - `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다. - 공개 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`로 떠 있다. - 공개용 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 확인 완료. - `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는 로컬 세션에 붙을 수 없다. - 로컬 웹이 `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가 보존됐다. - `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 분리 후 진행한다. - Docker `vignette-dev-db`는 실행 중이고 DB는 accepting connections다. 단, 기존 컨테이너라 healthcheck가 없고 `POSTGRES_USER=vignette` 기반이다. `vignette_app` role은 NOBYPASSRLS로 존재하지만 현재 API startup DDL이 owner 권한을 요구하므로 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행한다.
- 공개 런타임 재기동 스크립트: - 공개 런타임 재기동 스크립트:

View file

@ -450,6 +450,9 @@
<p class="dg-note">2차 적용(2026-06-27): 생성된 관리자/설정/리뷰 시안 기준으로 admin 역할의 상단바·사이드바를 어두운 운영 콘솔 크롬으로 전환했고, 관리자 사용자 관리 행을 조밀한 운영 테이블 밀도로 낮췄다. 회기 리뷰는 헤더·인사이트 카드·피드백 스테이지에 따뜻한 래스터 질감과 낮은 채도 틴트를 적용했다.</p> <p class="dg-note">2차 적용(2026-06-27): 생성된 관리자/설정/리뷰 시안 기준으로 admin 역할의 상단바·사이드바를 어두운 운영 콘솔 크롬으로 전환했고, 관리자 사용자 관리 행을 조밀한 운영 테이블 밀도로 낮췄다. 회기 리뷰는 헤더·인사이트 카드·피드백 스테이지에 따뜻한 래스터 질감과 낮은 채도 틴트를 적용했다.</p>
<p class="dg-note">3차 적용(2026-06-27): 상담 세션 시작/진행 화면을 시안 기준의 어두운 상담 스테이지로 재정렬했다. 시작 전 화면은 내담자 무대·브리핑·진행 초점 3영역으로 유지하고, 진행 중 화면은 어두운 캔버스 위에 중앙 스테이지와 자막/컨트롤을 배치한다.</p> <p class="dg-note">3차 적용(2026-06-27): 상담 세션 시작/진행 화면을 시안 기준의 어두운 상담 스테이지로 재정렬했다. 시작 전 화면은 내담자 무대·브리핑·진행 초점 3영역으로 유지하고, 진행 중 화면은 어두운 캔버스 위에 중앙 스테이지와 자막/컨트롤을 배치한다.</p>
<p class="dg-note">4차 적용(2026-06-27): 교수 콘솔은 시안의 triage 콘솔 구조에 맞춰 KPI를 전폭 6칸으로 재배치하고, 종료 회기 검토 큐와 최근 회기 기록을 첫 화면 2열 핵심 영역으로 올렸다. 성장 추적·페르소나 저작·검수 보조 큐는 하단 보조 업무로 낮췄고, 교수자 사이드바는 좁은 teal rail로 전환했다.</p> <p class="dg-note">4차 적용(2026-06-27): 교수 콘솔은 시안의 triage 콘솔 구조에 맞춰 KPI를 전폭 6칸으로 재배치하고, 종료 회기 검토 큐와 최근 회기 기록을 첫 화면 2열 핵심 영역으로 올렸다. 성장 추적·페르소나 저작·검수 보조 큐는 하단 보조 업무로 낮췄고, 교수자 사이드바는 좁은 teal rail로 전환했다.</p>
<p class="dg-note">5차 적용(2026-06-27): 회기 리뷰는 시안의 좌·중·우 작업대 구조에 맞춰 요약/액션 레일, 감정·흐름 중앙 영역, 우측 인사이트 패널로 재배치했다. 모바일에서는 트랜스크립트를 워크시트보다 먼저 노출하고, 사례개념화 워크시트는 제한 높이의 보조 패널로 낮춰 긴 문서처럼 보이지 않게 했다.</p>
<p class="dg-note">6차 적용(2026-06-27): 설정 화면은 글로벌 셸을 숨기고 설정 전용 좌측 레일을 주 내비로 승격했다. 계정, AI 운영, 테마, 음성, 알림 카드를 시안의 차분한 설정 워크스페이스 흐름에 맞춰 단일 컬럼으로 재배치했고, 모바일은 상단 고정 섹션 탭으로 모든 항목이 잘리지 않게 정리했다.</p>
<p class="dg-note">7차 적용(2026-06-27): 로그인 화면은 별도 생성한 래스터 상담실 배경(<code>login-counseling-room.png</code>)을 왼쪽 브랜드 패널에 적용해 시안의 실제 상담실 분위기를 반영했다. 학습자 홈은 모바일 grid 행 계산 때문에 생기던 리스트-상세 간 큰 공백을 제거하고, 회기 준비 보드가 한 흐름으로 이어지게 정리했다.</p>
<div class="dg-principles" aria-label="디자인 생성 가드레일"> <div class="dg-principles" aria-label="디자인 생성 가드레일">
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div> <div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div> <div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
@ -592,8 +595,8 @@
<div class="scard-body"><div class="kv"><b>현재</b><p><code>POST/GET/PUT /personas/drafts</code>를 추가해 teacher/admin이 페르소나 카드를 draft로 생성·조회·편집하고 review 상태로 올릴 수 있다. 교수 콘솔에는 JSON 초안 패널과 검수 큐의 편집 불러오기를 연결했다. 승인된 카드만 학습자 catalog에 노출되는 기존 경계는 유지.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_persona_review.py -q</code> 19 passed, <code>pytest app/ -q</code> 119 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed.</p></div><div class="kv k-warn"><b>잔여</b><p>P4~P7 실제 임상 콘텐츠 적재, JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화는 후속. 임상팀이 제공하지 않은 페르소나 내용을 임의 생성하지 않는다.</p></div></div> <div class="scard-body"><div class="kv"><b>현재</b><p><code>POST/GET/PUT /personas/drafts</code>를 추가해 teacher/admin이 페르소나 카드를 draft로 생성·조회·편집하고 review 상태로 올릴 수 있다. 교수 콘솔에는 JSON 초안 패널과 검수 큐의 편집 불러오기를 연결했다. 승인된 카드만 학습자 catalog에 노출되는 기존 경계는 유지.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_persona_review.py -q</code> 19 passed, <code>pytest app/ -q</code> 119 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed.</p></div><div class="kv k-warn"><b>잔여</b><p>P4~P7 실제 임상 콘텐츠 적재, JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화는 후속. 임상팀이 제공하지 않은 페르소나 내용을 임의 생성하지 않는다.</p></div></div>
</article> </article>
<article class="scard" data-status="done" data-cat="보안·PII" data-owner="0"> <article class="scard" data-status="done" data-cat="보안·PII" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">H4 부분 DONE</span><span class="scard-mid"><span class="scard-title">PII 마스킹 + LLM 호출 감사</span><span class="scard-sum">날짜·금액·주소 정규식, metadata-only llm_call_log 적재. 전체 API 122.</span></span><span class="caret" aria-hidden="true"></span></button> <button class="scard-head" aria-expanded="false"><span class="chip c-done">H4 부분 DONE</span><span class="scard-mid"><span class="scard-title">PII 마스킹 + LLM 호출 감사 + 동의 게이트</span><span class="scard-sum">metadata-only llm_call_log, learner consent_at 하드게이트. 전체 API 125.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 <code>/turn</code> smoke에서 <code>audit.llm_call_log</code>가 8→11로 3행 증가했다. 검증: 마스킹/감사 5 passed, 관련 회귀 27 passed, 전체 API 122 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시), 미성년/guardian 동의 수집·게이트·철회. 공개 Google OAuth 실제 <code>/turn</code> proof는 별도 운영 게이트.</p></div></div> <div class="scard-body"><div class="kv"><b>현재</b><p>정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 <code>/turn</code> smoke에서 <code>audit.llm_call_log</code>가 8→11로 3행 증가했다. 이번 패스에서 <code>app_user.consent_at</code> 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 125 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>.</p></div><div class="kv k-warn"><b>잔여</b><p>이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 <code>/turn</code> proof는 별도 운영 게이트.</p></div></div>
</article> </article>
<article class="scard" data-status="planned" data-cat="리팩터·정리" data-owner="0"> <article class="scard" data-status="planned" data-cat="리팩터·정리" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-plan">MED M1~M3</span><span class="scard-mid"><span class="scard-title">비언어 임상이벤트(M1)·다회기 케이스 아크(M2)·SSO claim/식별자/감사(M3)</span><span class="scard-sum">M1/M2 1차 완료. M3는 cohort map + provider external_id 1차 완료, 기관 SAML/거버넌스는 남음.</span></span><span class="caret" aria-hidden="true"></span></button> <button class="scard-head" aria-expanded="false"><span class="chip c-plan">MED M1~M3</span><span class="scard-mid"><span class="scard-title">비언어 임상이벤트(M1)·다회기 케이스 아크(M2)·SSO claim/식별자/감사(M3)</span><span class="scard-sum">M1/M2 1차 완료. M3는 cohort map + provider external_id 1차 완료, 기관 SAML/거버넌스는 남음.</span></span><span class="caret" aria-hidden="true"></span></button>
@ -605,7 +608,7 @@
</article> </article>
</div> </div>
<div class="detail-blocks"> <div class="detail-blocks">
<div class="detail-block"><h4>즉시 착수 가능(내부 코드, 외부 합의 불요)</h4><ul><li>C1/C2/C3/H2/H3은 1차 구조 완료. 후속은 저장형 워크시트, 임상 스크립트, CBT 체인, 2열 리뷰 UI, P4~P7 콘텐츠 적재처럼 데이터·정책·UI 고도화 영역이다.</li><li>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 서명검증은 후속.</li></ul></div> <div class="detail-block"><h4>즉시 착수 가능(내부 코드, 외부 합의 불요)</h4><ul><li>C1/C2/C3/H2/H3은 1차 구조 완료. 후속은 저장형 워크시트, 임상 스크립트, CBT 체인, 2열 리뷰 UI, P4~P7 콘텐츠 적재처럼 데이터·정책·UI 고도화 영역이다.</li><li>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 서명검증은 후속.</li></ul></div>
<div class="detail-block"><h4>소유자 결정 / 외부(임상팀·기관) 의존</h4><ul><li>임상팀 산출물: C1 워크시트 항목·루브릭, C3 CBT 콘텐츠·체인, C2 위기 스크립트·서약 문안, H2 골든셋 코딩.</li><li>소유자 평가설계: H1 실험/통제군·3척도 문항·50분 흐름, κ/ICC·환각률 목표(doc4 미명시).</li><li>기관(한신 IT): M3 SSO IdP·test tenant·claim 스키마·인증서, 거버넌스 증거.</li><li>거버넌스: L1 스택 정합성 방향·저작권 등재 문서화·IP 협의.</li></ul></div> <div class="detail-block"><h4>소유자 결정 / 외부(임상팀·기관) 의존</h4><ul><li>임상팀 산출물: C1 워크시트 항목·루브릭, C3 CBT 콘텐츠·체인, C2 위기 스크립트·서약 문안, H2 골든셋 코딩.</li><li>소유자 평가설계: H1 실험/통제군·3척도 문항·50분 흐름, κ/ICC·환각률 목표(doc4 미명시).</li><li>기관(한신 IT): M3 SSO IdP·test tenant·claim 스키마·인증서, 거버넌스 증거.</li><li>거버넌스: L1 스택 정합성 방향·저작권 등재 문서화·IP 협의.</li></ul></div>
<div class="detail-block"><h4>판독 한계 (정직성)</h4><ul><li>핵심 축어록 2단 표는 PDF 시각 판독이 1차 근거(txt 일부 누락), OCR 잔재 존재.</li><li>doc4 κ/ICC·환각률은 신청서 본문 미명시 — 계약 확정 지표로 단정 금지.</li><li>'현재상태'는 grep/코드 사실 기반. C1~C3·H2·H3은 직접 재확인(✓), 그 외는 착수 전 코드 1차 재확인 권고.</li></ul></div> <div class="detail-block"><h4>판독 한계 (정직성)</h4><ul><li>핵심 축어록 2단 표는 PDF 시각 판독이 1차 근거(txt 일부 누락), OCR 잔재 존재.</li><li>doc4 κ/ICC·환각률은 신청서 본문 미명시 — 계약 확정 지표로 단정 금지.</li><li>'현재상태'는 grep/코드 사실 기반. C1~C3·H2·H3은 직접 재확인(✓), 그 외는 착수 전 코드 1차 재확인 권고.</li></ul></div>
</div> </div>
@ -641,7 +644,7 @@
</article> </article>
<article class="scard" data-status="done" data-cat="인프라·스택·RAG" data-owner="0"> <article class="scard" data-status="done" data-cat="인프라·스택·RAG" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">RAG 활성화</span><span class="scard-mid"><span class="scard-title">"좋은 RAG" 미배선 → 라이브 활성화 + 성능 회귀 수정</span><span class="scard-sum">BGE-M3 핀+배선, 임베더 동기로드 블로킹을 백그라운드 task로. E2E 27/27·11/11·4/4.</span></span><span class="caret" aria-hidden="true"></span></button> <button class="scard-head" aria-expanded="false"><span class="chip c-done">RAG 활성화</span><span class="scard-mid"><span class="scard-title">"좋은 RAG" 미배선 → 라이브 활성화 + 성능 회귀 수정</span><span class="scard-sum">BGE-M3 핀+배선, 임베더 동기로드 블로킹을 백그라운드 task로. E2E 27/27·11/11·4/4.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>임베더 BGE-M3 검증+버전핀(1.4.0 dtype버그→1.2.11/transformers 4.44.2). <code>embed_query</code>를 asyncio.to_thread로 offload. 턴 파이프라인 배선: <code>retrieve_persona_memory</code>→episodic recall, <code>search_kb</code>→kb_behavior_cues. KB seed 3청크 적재. rag.py latent 버그 3건 수정. GraphRAG는 2차 보류.</p></div><div class="kv"><b>성능 회귀 수정</b><p>최초 RAG 배선이 회기 시작/턴에 임베더 로드(~7-13s)를 <b>동기 await</b><code>POST /sessions</code>가 막힘 → session-layout E2E 4건 타임아웃. 수정: 회상·KB 회수를 <code>_warm_rag_caches</code> <b>백그라운드 task</b>로 옮기고 턴은 캐시만 읽음(비차단). 재검증: session-layout 4/4 + <b>핵심 E2E 27/27·11/11·아바타 4/4</b> 통과.</p></div><div class="kv"><b>시각 게이트</b><p>admin 1280 input "text-clipped-x" 1건은 폼 input 값 스크롤(설계상 정상) 오탐 — form 컨트롤을 text-clip 판정에서 제외 → <b>게이트 7/7</b>(실 결함 검사 유지).</p></div></div> <div class="scard-body"><div class="kv"><b>현재</b><p>임베더 BGE-M3 검증+버전핀(1.4.0 dtype버그→1.2.11/transformers 4.44.2). <code>embed_query</code>를 asyncio.to_thread로 offload. 턴 파이프라인 배선: <code>retrieve_persona_memory</code>→episodic recall, <code>search_kb</code>→kb_behavior_cues. KB seed 3청크 적재. rag.py latent 버그 3건 수정. GraphRAG는 2차 보류.</p></div><div class="kv"><b>성능 회귀 수정</b><p>최초 RAG 배선이 회기 시작/턴에 임베더 로드(~7-13s)를 <b>동기 await</b><code>POST /sessions</code>가 막힘 → session-layout E2E 4건 타임아웃. 수정: 회상·KB 회수를 <code>_warm_rag_caches</code> <b>백그라운드 task</b>로 옮기고 턴은 캐시만 읽음(비차단). 이번 동의 게이트 E2E 재검증 중 여러 세션 warm task가 BGE-M3를 동시에 지연 로드해 API health/dev-login timeout이 반복된 문제는 embedder load/encode <code>RLock</code>과 warm semaphore 1개로 안정화했다. 재검증: session-layout 4/4 + <b>핵심 E2E 27/27·11/11·아바타 4/4</b>, 최신 layout/session 조합 <b>11/11</b> 통과.</p></div><div class="kv"><b>시각 게이트</b><p>admin 1280 input "text-clipped-x" 1건은 폼 input 값 스크롤(설계상 정상) 오탐 — form 컨트롤을 text-clip 판정에서 제외 → <b>게이트 7/7</b>(실 결함 검사 유지).</p></div></div>
</article> </article>
</div> </div>
</div> </div>
@ -793,6 +796,7 @@
<tr><td>Turn evaluation SSOP</td><td><code>pytest app/test_session_turn_persistence.py app/test_evaluation_persistence.py app/test_voice_ws.py</code></td><td>20 tests OK; submit/stream/voice fast-loop evaluation normalized mapping + evaluator-only raw RLS + turn insert id 확인</td></tr> <tr><td>Turn evaluation SSOP</td><td><code>pytest app/test_session_turn_persistence.py app/test_evaluation_persistence.py app/test_voice_ws.py</code></td><td>20 tests OK; submit/stream/voice fast-loop evaluation normalized mapping + evaluator-only raw RLS + turn insert id 확인</td></tr>
<tr><td>Postgres RLS/audit smoke checker</td><td><code>python scripts\check-postgres-rls-audit.py --help</code></td><td>py_compile/help/safe default OK; live DSN run still required for proof</td></tr> <tr><td>Postgres RLS/audit smoke checker</td><td><code>python scripts\check-postgres-rls-audit.py --help</code></td><td>py_compile/help/safe default OK; live DSN run still required for proof</td></tr>
<tr><td>OAuth/BFF cookie security</td><td><code>app.test_auth_providers</code></td><td>20 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</td></tr> <tr><td>OAuth/BFF cookie security</td><td><code>app.test_auth_providers</code></td><td>20 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</td></tr>
<tr><td>H4 consent gate</td><td><code>pytest app/test_auth_providers.py app/test_session_turn_persistence.py app/test_persona_review.py app/test_voice_ws.py -q</code></td><td>64 passed; learner consent accept/withdraw, non-learner/unaccepted reject, no-consent session start 403 <code>consent_required</code>, voice dev persona consent guard, frontend generated API contract updated</td></tr>
<tr><td>Persona review workflow</td><td><code>app.test_persona_review</code></td><td>19 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approve/reject audit, learner 403</td></tr> <tr><td>Persona review workflow</td><td><code>app.test_persona_review</code></td><td>19 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approve/reject audit, learner 403</td></tr>
<tr><td>Voice WS contract</td><td><code>app.test_voice_ws</code></td><td>5 tests OK; auth guard, audio_start/chunks/audio_end, text_turn, ping, max audio cap</td></tr> <tr><td>Voice WS contract</td><td><code>app.test_voice_ws</code></td><td>5 tests OK; auth guard, audio_start/chunks/audio_end, text_turn, ping, max audio cap</td></tr>
<tr><td>Voice preset/EOT</td><td><code>app.test_voice_service</code></td><td>11 tests OK; TTS payload, preset fallback, P1 sample TTS, EOT readiness</td></tr> <tr><td>Voice preset/EOT</td><td><code>app.test_voice_service</code></td><td>11 tests OK; TTS payload, preset fallback, P1 sample TTS, EOT readiness</td></tr>
@ -819,7 +823,7 @@
<tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr> <tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr>
<tr><td>Public runtime scripts</td><td><code>start/watch/install-public-runtime*.ps1</code></td><td>parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live <code>api-vnet.18ka.net</code>; add future domains explicitly with <code>-AdditionalPublicHealthUrls</code>.</td></tr> <tr><td>Public runtime scripts</td><td><code>start/watch/install-public-runtime*.ps1</code></td><td>parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live <code>api-vnet.18ka.net</code>; add future domains explicitly with <code>-AdditionalPublicHealthUrls</code>.</td></tr>
<tr><td>Public API health</td><td><code>https://api-vignette.chanpaca.net/health</code></td><td>prod, db true, engine true</td></tr> <tr><td>Public API health</td><td><code>https://api-vignette.chanpaca.net/health</code></td><td>prod, db true, engine true</td></tr>
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>http://localhost:5173/login</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>public 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 → <code>token_exchange_failed</code>, not <code>invalid_state</code>), provider callback error → <code>oauth=access_denied</code>, OAuth callback 실패는 <code>api.public.err.log</code>에 reason/status만 비밀값 없이 남김, 로그인 화면은 reason code 표시, local/Tailnet Google direct → <code>local_oauth_unavailable</code>, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed</td></tr> <tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>http://localhost:5173/login</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-27 재확인: 모바일 502 시각의 cloudflared 로그는 <code>127.0.0.1:8001</code> origin refused였고 현재 public login 200, public API health prod/db/engine true. Tailnet 403은 stale Vite allowedHosts 문제였고 <code>start-tailscale-runtime.ps1</code> 재기동 후 login 200 + `/api/health` dev/db/engine true. public Google auth redirect 302 with HttpOnly state cookie, local/Tailnet Google direct → <code>local_oauth_unavailable</code>.</td></tr>
<tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr> <tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr>
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr> <tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr> <tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>

View file

@ -62,7 +62,7 @@
| **H1** | 계약 평가 KPI(자기효능감·기술숙련도·수련만족도 사전사후) 수집·집계 전무 | `자기효능감/사전사후/수련만족/실험통제군` grep 0건. Phase3 KPI도 report shape만, 계산 코드 0줄. (분석) | 3척도 pre-post 폼·실험/통제군 배정·자동누적 대시보드·추이 시각화·검정 계산 코드. (κ/ICC·환각률은 doc4 미명시 → 평가설계 확정.) | doc4(20명 실험/통제군·단회기 50분·3척도 pre-post) | | **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(골드 포맷) | | **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(페르소나=전문가 산출물) | | **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 ### Medium+ (6) — M1~M3, X1~X2, L1
@ -90,7 +90,7 @@
- **C3 1차 완료**: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI. - **C3 1차 완료**: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
- **H2 1차 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋. - **H2 1차 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
- **H3**: 페르소나 저작 CRUD(draft→review) + P4~P7 로드 경로. - **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. 소유자 결정 / 외부(임상팀·기관) 의존 ### B. 소유자 결정 / 외부(임상팀·기관) 의존

View file

@ -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.** - 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`.** - 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.** - 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 분리 후 진행해야 한다.** - 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 분리 후 진행해야 한다.**

View file

@ -76,8 +76,8 @@
### [high] H4. PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여 ### [high] H4. PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여
- **근거**: doc1/2/5(실명·날짜·장소·금액·미성년·자살시도 다수), doc4(IRB·개인정보) - **근거**: 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 컬럼만, 동의 수집/게이트/철회 엔드포인트 전무. - **현재상태**(재검증): 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 동의 수집·하드게이트·철회. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. - **권고**: 한국어 이름/기관 NER 추가, 미성년/guardian 및 법무 검토가 필요한 서명 동의서/개인정보 고지 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트.
### [medium] M1. 비언어/준언어 임상 이벤트 캡처·태깅 부재 ### [medium] M1. 비언어/준언어 임상 이벤트 캡처·태깅 부재
- **근거**: doc1/2/5(침묵 초·한숨·울음·떨림을 1급 단서) - **근거**: doc1/2/5(침묵 초·한숨·울음·떨림을 1급 단서)
@ -115,7 +115,7 @@
- C3 1차 완료: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI. - C3 1차 완료: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
- H2 1차 완료: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋. - H2 1차 완료: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
- H3: 페르소나 저작 CRUD + P4~P7 로드 경로. - 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 실적재는 후속. - 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. 소유자 결정 / 외부(임상팀·기관) 의존 ### B. 소유자 결정 / 외부(임상팀·기관) 의존

View file

@ -25,6 +25,7 @@
- gateway `127.0.0.1:9099` - gateway `127.0.0.1:9099`
- Tailnet API `127.0.0.1:8010` - Tailnet API `127.0.0.1:8010`
- web `127.0.0.1:5173` - 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 상태 ## 공개 vnet.18ka.net 상태