동의 게이트와 런타임 안정화
This commit is contained in:
parent
0eb7d925ed
commit
0ec266a761
34 changed files with 1186 additions and 158 deletions
|
|
@ -28,6 +28,7 @@ class SessionUser:
|
|||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
consent_at: float | None
|
||||
expires_at: float
|
||||
|
||||
|
||||
|
|
@ -39,6 +40,7 @@ class ManagedUser:
|
|||
role: str
|
||||
cohort_ids: list[str]
|
||||
affiliation: str
|
||||
consent_at: float | None
|
||||
created_at: float
|
||||
last_seen_at: float
|
||||
|
||||
|
|
@ -95,6 +97,17 @@ def _ts(value: datetime | None) -> float:
|
|||
return (value or datetime.now(timezone.utc)).timestamp()
|
||||
|
||||
|
||||
def _optional_ts(value: datetime | None) -> float | None:
|
||||
return value.timestamp() if value is not None else None
|
||||
|
||||
|
||||
def _row_value(row, key: str, default=None):
|
||||
try:
|
||||
return row[key]
|
||||
except (IndexError, KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _cohort_ids(cohort: str | None) -> list[str]:
|
||||
return [cohort] if cohort else []
|
||||
|
||||
|
|
@ -111,9 +124,9 @@ async def _runtime_tables_ready(conn) -> bool:
|
|||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'app_user'
|
||||
AND column_name IN ('affiliation', 'last_seen_at', 'updated_at')
|
||||
AND column_name IN ('affiliation', 'consent_at', 'last_seen_at', 'updated_at')
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 3
|
||||
HAVING count(*) = 4
|
||||
) AS has_user_columns,
|
||||
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
|
||||
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
|
||||
|
|
@ -214,6 +227,7 @@ async def ensure_runtime_tables() -> None:
|
|||
"""
|
||||
ALTER TABLE app.app_user
|
||||
ADD COLUMN IF NOT EXISTS affiliation TEXT NOT NULL DEFAULT '',
|
||||
ADD COLUMN IF NOT EXISTS consent_at TIMESTAMPTZ,
|
||||
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
"""
|
||||
|
|
@ -388,8 +402,9 @@ def _managed_user_from_row(row) -> ManagedUser:
|
|||
email=row["email"],
|
||||
display_name=row["display_name"] or row["email"],
|
||||
role=_app_role(row["role"]),
|
||||
cohort_ids=_cohort_ids(row.get("cohort") if hasattr(row, "get") else row["cohort"]),
|
||||
cohort_ids=_cohort_ids(_row_value(row, "cohort")),
|
||||
affiliation=row["affiliation"] or DEFAULT_AFFILIATION,
|
||||
consent_at=_optional_ts(_row_value(row, "consent_at")),
|
||||
created_at=_ts(row["created_at"]),
|
||||
last_seen_at=_ts(row["last_seen_at"]),
|
||||
)
|
||||
|
|
@ -403,6 +418,7 @@ def _memory_upsert_managed_user(
|
|||
cohort_ids: list[str] | None = None,
|
||||
user_id: str | None = None,
|
||||
affiliation: str | None = None,
|
||||
consent_at: float | None = None,
|
||||
reactivate: bool = False,
|
||||
) -> ManagedUser:
|
||||
now = time.time()
|
||||
|
|
@ -424,6 +440,7 @@ def _memory_upsert_managed_user(
|
|||
if affiliation
|
||||
else (current.affiliation if current else DEFAULT_AFFILIATION)
|
||||
),
|
||||
consent_at=consent_at if consent_at is not None else (current.consent_at if current else None),
|
||||
created_at=current.created_at if current else now,
|
||||
last_seen_at=now,
|
||||
)
|
||||
|
|
@ -464,7 +481,7 @@ async def upsert_managed_user(
|
|||
last_seen_at = now(),
|
||||
updated_at = now()
|
||||
WHERE app.app_user.is_active OR $7
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
""",
|
||||
normalized_external_id,
|
||||
normalized_email,
|
||||
|
|
@ -485,6 +502,7 @@ async def upsert_managed_user(
|
|||
cohort_ids=user.cohort_ids,
|
||||
user_id=user.user_id,
|
||||
affiliation=user.affiliation,
|
||||
consent_at=user.consent_at,
|
||||
reactivate=True,
|
||||
)
|
||||
return user
|
||||
|
|
@ -509,7 +527,7 @@ async def get_managed_user(user_id: str) -> ManagedUser | None:
|
|||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
FROM app.app_user
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
""",
|
||||
|
|
@ -530,7 +548,7 @@ async def list_managed_users() -> tuple[list[ManagedUser], bool]:
|
|||
async with pool.acquire() as conn:
|
||||
rows = await conn.fetch(
|
||||
"""
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
SELECT user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
FROM app.app_user
|
||||
WHERE is_active
|
||||
ORDER BY last_seen_at DESC
|
||||
|
|
@ -563,7 +581,7 @@ async def update_managed_user(
|
|||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, created_at, last_seen_at
|
||||
RETURNING user_id, email, display_name, role, cohort, affiliation, consent_at, created_at, last_seen_at
|
||||
""",
|
||||
user_id,
|
||||
display_name.strip() if display_name is not None else None,
|
||||
|
|
@ -581,12 +599,14 @@ async def update_managed_user(
|
|||
cohort_ids=next_user.cohort_ids,
|
||||
user_id=next_user.user_id,
|
||||
affiliation=next_user.affiliation,
|
||||
consent_at=next_user.consent_at,
|
||||
)
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.display_name = next_user.display_name
|
||||
session.role = next_user.role
|
||||
session.cohort_ids = list(next_user.cohort_ids)
|
||||
session.consent_at = next_user.consent_at
|
||||
return next_user
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("managed user update")
|
||||
|
|
@ -604,6 +624,7 @@ async def update_managed_user(
|
|||
role=role if role is not None else current.role,
|
||||
cohort_ids=list(cohort_ids) if cohort_ids is not None else current.cohort_ids,
|
||||
affiliation=affiliation.strip() if affiliation is not None else current.affiliation,
|
||||
consent_at=current.consent_at,
|
||||
created_at=current.created_at,
|
||||
last_seen_at=time.time(),
|
||||
)
|
||||
|
|
@ -614,9 +635,118 @@ async def update_managed_user(
|
|||
session.display_name = next_user.display_name
|
||||
session.role = next_user.role
|
||||
session.cohort_ids = list(next_user.cohort_ids)
|
||||
session.consent_at = next_user.consent_at
|
||||
return next_user
|
||||
|
||||
|
||||
async def record_user_consent(user_id: str) -> float | None:
|
||||
"""Mark a learner consent receipt timestamp for the current user."""
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
UPDATE app.app_user SET
|
||||
consent_at = now(),
|
||||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
RETURNING consent_at
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
if row is not None:
|
||||
consent_at = _optional_ts(row["consent_at"])
|
||||
memory_user = _users.get(user_id)
|
||||
if memory_user is not None:
|
||||
_users[user_id] = ManagedUser(
|
||||
user_id=memory_user.user_id,
|
||||
email=memory_user.email,
|
||||
display_name=memory_user.display_name,
|
||||
role=memory_user.role,
|
||||
cohort_ids=list(memory_user.cohort_ids),
|
||||
affiliation=memory_user.affiliation,
|
||||
consent_at=consent_at,
|
||||
created_at=memory_user.created_at,
|
||||
last_seen_at=time.time(),
|
||||
)
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.consent_at = consent_at
|
||||
return consent_at
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("consent update")
|
||||
|
||||
if not runtime_fallback_allowed():
|
||||
return None
|
||||
current = _users.get(user_id)
|
||||
if current is None:
|
||||
return None
|
||||
consent_at = time.time()
|
||||
_users[user_id] = ManagedUser(
|
||||
user_id=current.user_id,
|
||||
email=current.email,
|
||||
display_name=current.display_name,
|
||||
role=current.role,
|
||||
cohort_ids=list(current.cohort_ids),
|
||||
affiliation=current.affiliation,
|
||||
consent_at=consent_at,
|
||||
created_at=current.created_at,
|
||||
last_seen_at=consent_at,
|
||||
)
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.consent_at = consent_at
|
||||
return consent_at
|
||||
|
||||
|
||||
async def withdraw_user_consent(user_id: str) -> bool:
|
||||
"""Clear consent and revoke practice eligibility until the user consents again."""
|
||||
changed = False
|
||||
try:
|
||||
pool = get_pool()
|
||||
async with pool.acquire() as conn:
|
||||
result = await conn.execute(
|
||||
"""
|
||||
UPDATE app.app_user SET
|
||||
consent_at = NULL,
|
||||
updated_at = now(),
|
||||
last_seen_at = now()
|
||||
WHERE user_id = $1::uuid AND is_active
|
||||
""",
|
||||
user_id,
|
||||
)
|
||||
changed = result.endswith(" 1")
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("consent withdrawal")
|
||||
|
||||
if not runtime_fallback_allowed():
|
||||
return changed
|
||||
current = _users.get(user_id)
|
||||
if current is not None:
|
||||
_users[user_id] = ManagedUser(
|
||||
user_id=current.user_id,
|
||||
email=current.email,
|
||||
display_name=current.display_name,
|
||||
role=current.role,
|
||||
cohort_ids=list(current.cohort_ids),
|
||||
affiliation=current.affiliation,
|
||||
consent_at=None,
|
||||
created_at=current.created_at,
|
||||
last_seen_at=time.time(),
|
||||
)
|
||||
changed = True
|
||||
for session in _sessions.values():
|
||||
if session.user_id == user_id:
|
||||
session.consent_at = None
|
||||
return changed
|
||||
|
||||
|
||||
async def user_has_consent(user_id: str) -> bool:
|
||||
managed = await get_managed_user(user_id)
|
||||
return bool(managed and managed.consent_at is not None)
|
||||
|
||||
|
||||
async def deactivate_managed_user(user_id: str) -> bool:
|
||||
"""Deactivate a managed user and revoke their active browser sessions."""
|
||||
changed = False
|
||||
|
|
@ -718,6 +848,7 @@ async def create_session(
|
|||
display_name=managed.display_name,
|
||||
role=managed.role,
|
||||
cohort_ids=list(managed.cohort_ids),
|
||||
consent_at=managed.consent_at,
|
||||
expires_at=expires_at,
|
||||
)
|
||||
sid_hash = _sid_hash(raw_sid)
|
||||
|
|
@ -767,7 +898,8 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
u.email,
|
||||
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
|
||||
u.role,
|
||||
u.cohort
|
||||
u.cohort,
|
||||
u.consent_at
|
||||
FROM app.auth_session s
|
||||
JOIN app.app_user u ON u.user_id = s.user_id
|
||||
WHERE s.sid_hash = $1
|
||||
|
|
@ -792,6 +924,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
display_name=row["display_name"],
|
||||
role=_app_role(row["role"]),
|
||||
cohort_ids=_cohort_ids(row["cohort"]),
|
||||
consent_at=_optional_ts(row["consent_at"]),
|
||||
expires_at=_ts(row["expires_at"]),
|
||||
)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -35,12 +35,14 @@ class Principal:
|
|||
cohort_ids: Optional[list[str]] = None,
|
||||
email: str = "",
|
||||
display_name: str = "",
|
||||
consent_at: float | None = None,
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.role = role
|
||||
self.cohort_ids = cohort_ids or []
|
||||
self.email = email
|
||||
self.display_name = display_name
|
||||
self.consent_at = consent_at
|
||||
|
||||
|
||||
def get_settings_dep() -> Settings:
|
||||
|
|
@ -74,6 +76,7 @@ async def get_current_principal(
|
|||
cohort_ids=session.cohort_ids,
|
||||
email=session.email,
|
||||
display_name=session.display_name,
|
||||
consent_at=session.consent_at,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -25,7 +25,14 @@ from fastapi import APIRouter, Cookie, HTTPException, Query, Request, Response,
|
|||
from fastapi.responses import RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
|
||||
from ..auth_sessions import (
|
||||
InactiveUserError,
|
||||
SessionUser,
|
||||
create_session,
|
||||
record_user_consent,
|
||||
revoke_session,
|
||||
withdraw_user_consent,
|
||||
)
|
||||
from ..config import settings
|
||||
from ..deps import CurrentPrincipal, Principal, Role
|
||||
from ..saml import (
|
||||
|
|
@ -71,6 +78,15 @@ class MeResponse(BaseModel):
|
|||
display_name: str
|
||||
role: str
|
||||
cohort_ids: list[str]
|
||||
consent_at: float | None = None
|
||||
|
||||
|
||||
class ConsentRequest(BaseModel):
|
||||
accepted: bool = True
|
||||
|
||||
|
||||
class ConsentResponse(BaseModel):
|
||||
consent_at: float | None = None
|
||||
|
||||
|
||||
class AuthProviderStatus(BaseModel):
|
||||
|
|
@ -564,6 +580,7 @@ def _me_response(user: SessionUser | Principal) -> MeResponse:
|
|||
display_name=getattr(user, "display_name", "") or getattr(user, "email", ""),
|
||||
role=user.role.value if isinstance(user.role, Role) else user.role,
|
||||
cohort_ids=user.cohort_ids,
|
||||
consent_at=getattr(user, "consent_at", None),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -933,6 +950,35 @@ async def logout(
|
|||
return {"ok": True}
|
||||
|
||||
|
||||
@router.post("/consent", response_model=ConsentResponse)
|
||||
async def accept_consent(
|
||||
body: ConsentRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> ConsentResponse:
|
||||
"""Record the current learner's practice-session consent receipt."""
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
if not body.accepted:
|
||||
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="consent_not_accepted")
|
||||
consent_at = await record_user_consent(principal.user_id)
|
||||
if consent_at is None:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
principal.consent_at = consent_at
|
||||
return ConsentResponse(consent_at=consent_at)
|
||||
|
||||
|
||||
@router.delete("/consent", response_model=ConsentResponse)
|
||||
async def withdraw_consent(principal: CurrentPrincipal) -> ConsentResponse:
|
||||
"""Withdraw practice-session consent until the learner accepts again."""
|
||||
if principal.role != Role.LEARNER:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="learner consent only")
|
||||
changed = await withdraw_user_consent(principal.user_id)
|
||||
if not changed:
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
|
||||
principal.consent_at = None
|
||||
return ConsentResponse(consent_at=None)
|
||||
|
||||
|
||||
@router.get("/me", response_model=MeResponse)
|
||||
async def me(principal: CurrentPrincipal) -> MeResponse:
|
||||
"""Return the current authenticated user. Unauthenticated requests are 401."""
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from pydantic import BaseModel, Field
|
|||
from sse_starlette.sse import EventSourceResponse
|
||||
|
||||
from .. import db, session_persistence, turn_runtime
|
||||
from ..auth_sessions import user_has_consent
|
||||
from ..config import settings
|
||||
from ..deps import CurrentPrincipal, Principal, Role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
|
|
@ -240,6 +241,7 @@ class SessionReviewResponse(BaseModel):
|
|||
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
|
||||
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
|
||||
_KB_CUES_CACHE: dict[str, list[str]] = {}
|
||||
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
|
||||
_LEARNER_VISIBLE_AI_ROLE = "counselor"
|
||||
|
||||
# ────────────────────────────────────────────────────────────────────────────
|
||||
|
|
@ -409,14 +411,15 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
|
|||
BGE-M3 임베더 첫 로드(~수 초)가 회기 시작/턴 응답을 막지 않도록 create_task로 띄운다.
|
||||
warm 완료 전 턴은 빈 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 전 구간 비치명적.
|
||||
"""
|
||||
try:
|
||||
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
|
||||
except Exception:
|
||||
pass
|
||||
async with _RAG_WARM_SEMAPHORE:
|
||||
try:
|
||||
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
_PHASE_KEY_BY_LABEL = {
|
||||
|
|
@ -436,6 +439,14 @@ def _ensure_learner(principal: Principal) -> None:
|
|||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
|
||||
|
||||
|
||||
async def _ensure_practice_consent(principal: Principal) -> None:
|
||||
if principal.consent_at is not None:
|
||||
return
|
||||
if await user_has_consent(principal.user_id):
|
||||
return
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="consent_required")
|
||||
|
||||
|
||||
async def _load_session_or_404(
|
||||
session_id: str,
|
||||
principal: Principal,
|
||||
|
|
@ -1149,6 +1160,7 @@ async def start_session(
|
|||
) -> SessionStartResponse:
|
||||
"""Start a learner-owned practice session."""
|
||||
_ensure_learner(principal)
|
||||
await _ensure_practice_consent(principal)
|
||||
|
||||
try:
|
||||
catalog_persona = await get_catalog_persona(body.persona_code)
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from fastapi.responses import JSONResponse
|
|||
from starlette.websockets import WebSocketState
|
||||
|
||||
from .. import session_persistence, turn_runtime
|
||||
from ..auth_sessions import get_session
|
||||
from ..auth_sessions import get_session, user_has_consent
|
||||
from ..config import settings
|
||||
from ..deps import Principal, Role
|
||||
from ..engine_client import EngineError, engine_client
|
||||
|
|
@ -431,6 +431,7 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
|
|||
cohort_ids=session.cohort_ids,
|
||||
email=session.email,
|
||||
display_name=session.display_name,
|
||||
consent_at=session.consent_at,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -457,6 +458,8 @@ async def _bind_session(
|
|||
persona_code = qp.get("persona_code")
|
||||
if not persona_code:
|
||||
return None, None, "session_id or persona_code query required", {}
|
||||
if principal.consent_at is None and not await user_has_consent(principal.user_id):
|
||||
return None, None, "consent_required", {}
|
||||
try:
|
||||
catalog_persona = await get_catalog_persona(persona_code)
|
||||
except Exception:
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ from __future__ import annotations
|
|||
|
||||
import asyncio
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
|
@ -159,6 +160,7 @@ CRAG_TOP1_THRESHOLD = 0.35
|
|||
# ════════════════════════════════════════════════════════════════════════════
|
||||
_EMBEDDER: Any = None # FlagEmbedding.BGEM3FlagModel 인스턴스(지연 로딩 캐시)
|
||||
_EMBEDDER_FAILED = False # 모델 로드 실패 1회 기록(반복 시도 방지)
|
||||
_EMBEDDER_LOCK = threading.RLock()
|
||||
BGE_M3_MODEL = "BAAI/bge-m3"
|
||||
EMBED_DIM = 1024 # kb.chunk.embedding vector(1024) 와 정합 — 어기면 DB 캐스트 실패
|
||||
|
||||
|
|
@ -171,22 +173,25 @@ def _get_embedder() -> Any:
|
|||
global _EMBEDDER, _EMBEDDER_FAILED
|
||||
if _EMBEDDER is not None:
|
||||
return _EMBEDDER
|
||||
if _EMBEDDER_FAILED:
|
||||
raise NotConfigured("BGE-M3 embedder unavailable (prior load failure)")
|
||||
try:
|
||||
from FlagEmbedding import BGEM3FlagModel # 무거운 의존성 — 지연 import
|
||||
except Exception as e: # ImportError 포함(미설치 환경)
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(
|
||||
"FlagEmbedding(BGE-M3) not installed — requirements-rag.txt 필요"
|
||||
) from e
|
||||
try:
|
||||
# use_fp16: GPU 시 절반정밀(속도). CPU 면 무시됨.
|
||||
_EMBEDDER = BGEM3FlagModel(BGE_M3_MODEL, use_fp16=True)
|
||||
except Exception as e:
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(f"BGE-M3 model load failed: {e}") from e
|
||||
return _EMBEDDER
|
||||
with _EMBEDDER_LOCK:
|
||||
if _EMBEDDER is not None:
|
||||
return _EMBEDDER
|
||||
if _EMBEDDER_FAILED:
|
||||
raise NotConfigured("BGE-M3 embedder unavailable (prior load failure)")
|
||||
try:
|
||||
from FlagEmbedding import BGEM3FlagModel # 무거운 의존성 — 지연 import
|
||||
except Exception as e: # ImportError 포함(미설치 환경)
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(
|
||||
"FlagEmbedding(BGE-M3) not installed — requirements-rag.txt 필요"
|
||||
) from e
|
||||
try:
|
||||
# use_fp16: GPU 시 절반정밀(속도). CPU 면 무시됨.
|
||||
_EMBEDDER = BGEM3FlagModel(BGE_M3_MODEL, use_fp16=True)
|
||||
except Exception as e:
|
||||
_EMBEDDER_FAILED = True
|
||||
raise NotConfigured(f"BGE-M3 model load failed: {e}") from e
|
||||
return _EMBEDDER
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -202,13 +207,14 @@ def embed_query(text: str) -> EmbeddedQuery:
|
|||
|
||||
인덱싱 시점(오프라인 배치)에도 같은 함수로 청크 임베딩을 산출한다(동일 모델 재사용).
|
||||
"""
|
||||
model = _get_embedder()
|
||||
out = model.encode(
|
||||
[text],
|
||||
return_dense=True,
|
||||
return_sparse=True,
|
||||
return_colbert_vecs=False, # 멀티벡터는 런타임 회수에 미사용(인덱싱만)
|
||||
)
|
||||
with _EMBEDDER_LOCK:
|
||||
model = _get_embedder()
|
||||
out = model.encode(
|
||||
[text],
|
||||
return_dense=True,
|
||||
return_sparse=True,
|
||||
return_colbert_vecs=False, # 멀티벡터는 런타임 회수에 미사용(인덱싱만)
|
||||
)
|
||||
dense_vec = out["dense_vecs"][0]
|
||||
# numpy → list[float] (asyncpg pgvector 텍스트 캐스트 호환). tolist() 있으면 사용.
|
||||
dense = dense_vec.tolist() if hasattr(dense_vec, "tolist") else list(dense_vec)
|
||||
|
|
|
|||
|
|
@ -12,7 +12,9 @@ from fastapi import Response
|
|||
from starlette.requests import Request
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from . import auth_sessions
|
||||
from .config import Settings, settings
|
||||
from .deps import Principal, Role
|
||||
from .routes import auth as auth_routes
|
||||
from .saml import inflate_redirect_request
|
||||
|
||||
|
|
@ -94,10 +96,18 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
async def asyncSetUp(self) -> None:
|
||||
auth_routes._oauth_states.clear()
|
||||
auth_routes._saml_states.clear()
|
||||
auth_sessions._sessions.clear()
|
||||
auth_sessions._users.clear()
|
||||
auth_sessions._email_index.clear()
|
||||
auth_sessions._inactive_emails.clear()
|
||||
|
||||
async def asyncTearDown(self) -> None:
|
||||
auth_routes._oauth_states.clear()
|
||||
auth_routes._saml_states.clear()
|
||||
auth_sessions._sessions.clear()
|
||||
auth_sessions._users.clear()
|
||||
auth_sessions._email_index.clear()
|
||||
auth_sessions._inactive_emails.clear()
|
||||
|
||||
async def test_auth_config_reports_google_and_saml_provider_status(self) -> None:
|
||||
with patched_settings(
|
||||
|
|
@ -134,6 +144,60 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
self.assertTrue(config.dev_login_enabled)
|
||||
|
||||
async def test_learner_can_accept_and_withdraw_practice_consent(self) -> None:
|
||||
with patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")):
|
||||
_, user = await auth_sessions.create_session(
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
role="learner",
|
||||
external_id="dev:learner@hs.ac.kr",
|
||||
)
|
||||
|
||||
principal = Principal(
|
||||
user_id=user.user_id,
|
||||
role=Role.LEARNER,
|
||||
cohort_ids=user.cohort_ids,
|
||||
email=user.email,
|
||||
display_name=user.display_name,
|
||||
consent_at=user.consent_at,
|
||||
)
|
||||
accepted = await auth_routes.accept_consent(
|
||||
auth_routes.ConsentRequest(accepted=True),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(accepted.consent_at)
|
||||
self.assertEqual(principal.consent_at, accepted.consent_at)
|
||||
self.assertTrue(await auth_sessions.user_has_consent(user.user_id))
|
||||
|
||||
withdrawn = await auth_routes.withdraw_consent(principal)
|
||||
|
||||
self.assertIsNone(withdrawn.consent_at)
|
||||
self.assertIsNone(principal.consent_at)
|
||||
self.assertFalse(await auth_sessions.user_has_consent(user.user_id))
|
||||
|
||||
async def test_consent_rejects_non_learner_and_unaccepted_body(self) -> None:
|
||||
teacher = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000501",
|
||||
role=Role.TEACHER,
|
||||
email="teacher@hs.ac.kr",
|
||||
display_name="Teacher",
|
||||
)
|
||||
learner = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000502",
|
||||
role=Role.LEARNER,
|
||||
email="learner@hs.ac.kr",
|
||||
display_name="Learner",
|
||||
)
|
||||
|
||||
with self.assertRaises(auth_routes.HTTPException) as teacher_error:
|
||||
await auth_routes.accept_consent(auth_routes.ConsentRequest(), teacher)
|
||||
with self.assertRaises(auth_routes.HTTPException) as learner_error:
|
||||
await auth_routes.accept_consent(auth_routes.ConsentRequest(accepted=False), learner)
|
||||
|
||||
self.assertEqual(teacher_error.exception.status_code, 403)
|
||||
self.assertEqual(learner_error.exception.status_code, 400)
|
||||
|
||||
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
|
||||
request = _request(
|
||||
[
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ def _principal(role: Role = Role.LEARNER) -> Principal:
|
|||
cohort_ids=["cohort-a"] if role == Role.TEACHER else [],
|
||||
email=f"{role.value}@example.test",
|
||||
display_name=role.value.title(),
|
||||
consent_at=1.0 if role == Role.LEARNER else None,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ def _principal() -> Principal:
|
|||
cohort_ids=[],
|
||||
email="turn-test@hs.ac.kr",
|
||||
display_name="Turn Test",
|
||||
consent_at=1.0,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -374,6 +375,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(response.recall_summary, recall.recall_summary)
|
||||
self.assertIs(sessions._RECALL_CACHE[response.session_id], recall)
|
||||
|
||||
async def test_start_session_requires_learner_consent_before_catalog_lookup(self) -> None:
|
||||
principal = _principal()
|
||||
principal.consent_at = None
|
||||
|
||||
with patch.object(
|
||||
sessions,
|
||||
"get_catalog_persona",
|
||||
AsyncMock(side_effect=AssertionError("consent gate must run before catalog lookup")),
|
||||
) as get_persona:
|
||||
with self.assertRaises(sessions.HTTPException) as caught:
|
||||
await sessions.start_session(
|
||||
sessions.SessionStartRequest(persona_code=persona_service.P1.code),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
self.assertEqual(caught.exception.detail, "consent_required")
|
||||
get_persona.assert_not_awaited()
|
||||
|
||||
async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None:
|
||||
class FakeStreamEngine:
|
||||
engine_mode = "claude_cli"
|
||||
|
|
|
|||
|
|
@ -203,6 +203,9 @@ test.describe("layout visual gate @single-run", () => {
|
|||
display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
|
||||
},
|
||||
});
|
||||
await page.request.post("/api/auth/consent", {
|
||||
data: { accepted: true },
|
||||
});
|
||||
// Seed dense history: one active + two ended sessions.
|
||||
const persona = await fetchAvailablePersona(page);
|
||||
const made: string[] = [];
|
||||
|
|
|
|||
|
|
@ -267,6 +267,7 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
|
|||
template === "none" ? 0 : template.split(" ").filter(Boolean).length;
|
||||
const root = document.querySelector<HTMLElement>(".vg-set");
|
||||
const forms = document.querySelector<HTMLElement>(".vg-set__forms");
|
||||
const railTitle = document.querySelector<HTMLElement>(".vg-set__rail-title");
|
||||
const railCard = document.querySelector<HTMLElement>(".vg-set__rail-card");
|
||||
const nav = document.querySelector<HTMLElement>(".vg-set__nav");
|
||||
const account = document.querySelector<HTMLElement>("#set-account");
|
||||
|
|
@ -274,12 +275,24 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
|
|||
const notify = document.querySelector<HTMLElement>("#set-notify");
|
||||
const voice = document.querySelector<HTMLElement>("#set-voice");
|
||||
|
||||
if (!root || !forms || !railCard || !nav || !account || !appearance || !notify || !voice) {
|
||||
if (
|
||||
!root ||
|
||||
!forms ||
|
||||
!railTitle ||
|
||||
!railCard ||
|
||||
!nav ||
|
||||
!account ||
|
||||
!appearance ||
|
||||
!notify ||
|
||||
!voice
|
||||
) {
|
||||
return { ready: false };
|
||||
}
|
||||
|
||||
const rootStyle = getComputedStyle(root);
|
||||
const rootColumns = countColumns(getComputedStyle(root).gridTemplateColumns);
|
||||
const formColumns = countColumns(getComputedStyle(forms).gridTemplateColumns);
|
||||
const railTitleRect = railTitle.getBoundingClientRect();
|
||||
const railCardRect = railCard.getBoundingClientRect();
|
||||
const navRect = nav.getBoundingClientRect();
|
||||
const accountStyle = getComputedStyle(account);
|
||||
|
|
@ -290,20 +303,17 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
|
|||
if (layoutMode === "desktop") {
|
||||
return {
|
||||
ready: true,
|
||||
rootColumns,
|
||||
rootIsTwoColumnGrid: rootStyle.display === "grid" && rootColumns === 2,
|
||||
formColumns,
|
||||
railCardVisible: railCardRect.height > 24,
|
||||
shortPanelsShareRow:
|
||||
Math.abs(appearanceRect.top - notifyRect.top) <= 4 &&
|
||||
appearanceRect.left < notifyRect.left,
|
||||
voiceBelowShortPanels:
|
||||
voiceRect.top > appearanceRect.top && voiceRect.top > notifyRect.top,
|
||||
railTitleVisible: railTitleRect.height > 28,
|
||||
railCardHidden: railCardRect.height === 0,
|
||||
voiceBeforeNotify: voiceRect.top < notifyRect.top,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ready: true,
|
||||
rootColumns,
|
||||
rootSingleColumn: rootStyle.display !== "grid",
|
||||
formColumns,
|
||||
railCardHidden: railCardRect.height === 0,
|
||||
navSingleLine: navRect.height <= 54,
|
||||
|
|
@ -315,15 +325,15 @@ async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobi
|
|||
mode === "desktop"
|
||||
? {
|
||||
ready: true,
|
||||
rootColumns: 2,
|
||||
formColumns: 2,
|
||||
railCardVisible: true,
|
||||
shortPanelsShareRow: true,
|
||||
voiceBelowShortPanels: true,
|
||||
rootIsTwoColumnGrid: true,
|
||||
formColumns: 1,
|
||||
railTitleVisible: true,
|
||||
railCardHidden: true,
|
||||
voiceBeforeNotify: true,
|
||||
}
|
||||
: {
|
||||
ready: true,
|
||||
rootColumns: 1,
|
||||
rootSingleColumn: true,
|
||||
formColumns: 1,
|
||||
railCardHidden: true,
|
||||
navSingleLine: true,
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ export async function signInAsLearner(page: Page) {
|
|||
},
|
||||
});
|
||||
expect(res.ok(), await res.text()).toBeTruthy();
|
||||
const consent = await page.request.post("/api/auth/consent", {
|
||||
data: { accepted: true },
|
||||
});
|
||||
expect(consent.ok(), await consent.text()).toBeTruthy();
|
||||
}
|
||||
|
||||
export async function signInAsTeacher(page: Page) {
|
||||
|
|
|
|||
BIN
apps/web/public/design-elements/login-counseling-room.png
Normal file
BIN
apps/web/public/design-elements/login-counseling-room.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.6 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 2.4 MiB |
|
|
@ -275,6 +275,49 @@ body[data-role="admin"] .vg-nav__foot {
|
|||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
--nav-cur: 108px;
|
||||
background-image: linear-gradient(rgba(38, 88, 83, 0.16), rgba(38, 88, 83, 0.16));
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(32, 107, 99, 0.98), rgba(22, 83, 79, 0.98)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
color: #eef4f2;
|
||||
padding: var(--sp-5) 10px;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__label,
|
||||
body[data-role="instructor"] .vg-nav__ethic {
|
||||
color: rgba(238, 244, 242, 0.62);
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 64px;
|
||||
padding: 9px 8px;
|
||||
color: rgba(238, 244, 242, 0.82);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
|
||||
color: rgba(218, 242, 238, 0.78);
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item.is-active {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__foot {
|
||||
border-top-color: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
/* ── 메인 콘텐츠 ── */
|
||||
.vg-main {
|
||||
min-width: 0; /* 그리드 자식 overflow 방지 */
|
||||
|
|
@ -298,6 +341,9 @@ body[data-role="admin"] .vg-nav__foot {
|
|||
/* 그리드·구분선이 함께 축소 폭을 따른다 */
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -368,6 +414,36 @@ body[data-role="admin"] .vg-nav__foot {
|
|||
.vg-main {
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-7);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar {
|
||||
background: #1d7169;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__wm,
|
||||
body[data-role="instructor"] .vg-topbar__role,
|
||||
body[data-role="instructor"] .vg-topbar__uname,
|
||||
body[data-role="instructor"] .vg-iconbtn {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__wm .v,
|
||||
body[data-role="instructor"] .vg-topbar__mark {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__brand svg {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__role {
|
||||
border-left-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__user {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__avatar {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.vg-main--bleed {
|
||||
padding: 0;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -156,6 +156,30 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/consent": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Accept Consent
|
||||
* @description Record the current learner's practice-session consent receipt.
|
||||
*/
|
||||
post: operations["accept_consent_auth_consent_post"];
|
||||
/**
|
||||
* Withdraw Consent
|
||||
* @description Withdraw practice-session consent until the learner accepts again.
|
||||
*/
|
||||
delete: operations["withdraw_consent_auth_consent_delete"];
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/auth/dev-login": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1072,6 +1096,19 @@ export interface components {
|
|||
/** Rationale */
|
||||
rationale?: string | null;
|
||||
};
|
||||
/** ConsentRequest */
|
||||
ConsentRequest: {
|
||||
/**
|
||||
* Accepted
|
||||
* @default true
|
||||
*/
|
||||
accepted: boolean;
|
||||
};
|
||||
/** ConsentResponse */
|
||||
ConsentResponse: {
|
||||
/** Consent At */
|
||||
consent_at?: number | null;
|
||||
};
|
||||
/** CrisisResourceResponse */
|
||||
CrisisResourceResponse: {
|
||||
/** Message */
|
||||
|
|
@ -1294,6 +1331,8 @@ export interface components {
|
|||
MeResponse: {
|
||||
/** Cohort Ids */
|
||||
cohort_ids: string[];
|
||||
/** Consent At */
|
||||
consent_at?: number | null;
|
||||
/** Display Name */
|
||||
display_name: string;
|
||||
/** Email */
|
||||
|
|
@ -2586,6 +2625,74 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
accept_consent_auth_consent_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["ConsentRequest"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ConsentResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
withdraw_consent_auth_consent_delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["ConsentResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
dev_login_auth_dev_login_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -161,6 +161,11 @@ export interface MeResponse {
|
|||
display_name: string;
|
||||
role: string; // "learner" | "teacher" | "admin"
|
||||
cohort_ids: string[];
|
||||
consent_at: number | null;
|
||||
}
|
||||
|
||||
export interface ConsentResponse {
|
||||
consent_at: number | null;
|
||||
}
|
||||
|
||||
export interface AuthConfigResponse {
|
||||
|
|
@ -179,6 +184,8 @@ export interface AuthConfigResponse {
|
|||
|
||||
export const authApi = {
|
||||
config: () => api.get<AuthConfigResponse>("/auth/config"),
|
||||
acceptConsent: () => api.post<ConsentResponse>("/auth/consent", { accepted: true }),
|
||||
withdrawConsent: () => api.del<ConsentResponse>("/auth/consent"),
|
||||
};
|
||||
|
||||
export type SessionStage = "라포" | "탐색" | "개입" | "정리";
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import { api, type MeResponse } from "./api";
|
||||
import { api, authApi, type MeResponse } from "./api";
|
||||
|
||||
export type Role = "learner" | "teacher" | "admin";
|
||||
export type DesignRole = "learner" | "instructor" | "admin";
|
||||
|
|
@ -18,6 +18,7 @@ export interface AuthUser {
|
|||
name: string;
|
||||
role: Role;
|
||||
cohortIds: string[];
|
||||
consentAt: number | null;
|
||||
}
|
||||
|
||||
export interface AuthContextValue {
|
||||
|
|
@ -26,6 +27,8 @@ export interface AuthContextValue {
|
|||
loading: boolean;
|
||||
login: (role: Role, opts?: { email?: string; displayName?: string }) => Promise<AuthUser>;
|
||||
logout: () => Promise<void>;
|
||||
acceptConsent: () => Promise<void>;
|
||||
withdrawConsent: () => Promise<void>;
|
||||
}
|
||||
|
||||
export function designRoleOf(role: Role): DesignRole {
|
||||
|
|
@ -75,6 +78,7 @@ function userFromMe(me: MeResponse): AuthUser {
|
|||
name: me.display_name || me.email || me.user_id,
|
||||
role: (me.role as Role) ?? "learner",
|
||||
cohortIds: me.cohort_ids ?? [],
|
||||
consentAt: me.consent_at ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -124,9 +128,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||
}
|
||||
}, []);
|
||||
|
||||
const acceptConsent = useCallback<AuthContextValue["acceptConsent"]>(async () => {
|
||||
const response = await authApi.acceptConsent();
|
||||
setUser((current) =>
|
||||
current ? { ...current, consentAt: response.consent_at ?? null } : current,
|
||||
);
|
||||
}, []);
|
||||
|
||||
const withdrawConsent = useCallback<AuthContextValue["withdrawConsent"]>(async () => {
|
||||
await authApi.withdrawConsent();
|
||||
setUser((current) => (current ? { ...current, consentAt: null } : current));
|
||||
}, []);
|
||||
|
||||
const value = useMemo<AuthContextValue>(
|
||||
() => ({ user, role: user?.role ?? null, loading, login, logout }),
|
||||
[user, loading, login, logout],
|
||||
() => ({ user, role: user?.role ?? null, loading, login, logout, acceptConsent, withdrawConsent }),
|
||||
[user, loading, login, logout, acceptConsent, withdrawConsent],
|
||||
);
|
||||
|
||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||
|
|
|
|||
|
|
@ -848,7 +848,7 @@ const ADMIN_CSS = `
|
|||
content:"";
|
||||
position:absolute;
|
||||
z-index:-1;
|
||||
inset:-80px -180px auto auto;
|
||||
inset:-80px 0 auto auto;
|
||||
width:min(560px,52vw);
|
||||
height:420px;
|
||||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||||
|
|
|
|||
|
|
@ -438,13 +438,13 @@ const LH_CSS = `
|
|||
gap:clamp(12px,1.6vw,20px);
|
||||
padding:clamp(14px,2vw,28px);
|
||||
background:var(--bg-app);
|
||||
overflow:visible;
|
||||
overflow:hidden;
|
||||
}
|
||||
.lh-root::before{
|
||||
content:"";
|
||||
position:absolute;
|
||||
z-index:-1;
|
||||
inset:-120px -220px auto auto;
|
||||
inset:-120px 0 auto auto;
|
||||
width:min(720px,58vw);
|
||||
height:520px;
|
||||
background:var(--asset-warm-elements) center / cover no-repeat;
|
||||
|
|
@ -1044,6 +1044,11 @@ const LH_CSS = `
|
|||
.lh-head{
|
||||
align-items:flex-start;
|
||||
}
|
||||
.lh-workspace{
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:10px;
|
||||
}
|
||||
.lh-list-pane{
|
||||
padding:12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -370,8 +370,8 @@ const LOGIN_CSS = `
|
|||
gap:var(--sp-7);
|
||||
padding:var(--sp-7);
|
||||
background:
|
||||
linear-gradient(115deg,rgba(14,22,20,.94),rgba(30,39,36,.84) 54%,rgba(30,39,36,.68)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
linear-gradient(115deg,rgba(14,22,20,.86),rgba(30,39,36,.7) 48%,rgba(30,39,36,.48)),
|
||||
var(--asset-login-room) center / cover no-repeat;
|
||||
color:#edf4f2;
|
||||
overflow:hidden;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -290,23 +290,23 @@ export default function Professor() {
|
|||
const kpis = useMemo(
|
||||
() => [
|
||||
{
|
||||
label: "학습자",
|
||||
value: dashboard?.total_learners ?? 0,
|
||||
hint: "담당 코호트",
|
||||
icon: "users" as const,
|
||||
label: "검토 대기",
|
||||
value: dashboard?.pending_reviews.length ?? 0,
|
||||
hint: "종료 회기",
|
||||
icon: "review" as const,
|
||||
},
|
||||
{
|
||||
label: "진행 중",
|
||||
value: dashboard?.active_sessions ?? 0,
|
||||
hint: "열린 회기",
|
||||
icon: "play" as const,
|
||||
},
|
||||
{
|
||||
label: "종료",
|
||||
value: dashboard?.ended_sessions ?? 0,
|
||||
hint: "저장 완료",
|
||||
label: "페르소나 검수",
|
||||
value: personaReviews.length,
|
||||
hint: "승인 대기",
|
||||
icon: "check" as const,
|
||||
},
|
||||
{
|
||||
label: "최근 세션",
|
||||
value: dashboard?.recent_sessions.length ?? 0,
|
||||
hint: "종료 · 진행",
|
||||
icon: "play" as const,
|
||||
},
|
||||
{
|
||||
label: "위기 알림",
|
||||
value: dashboard?.safety_alerts.length ?? 0,
|
||||
|
|
@ -314,13 +314,19 @@ export default function Professor() {
|
|||
icon: "alert" as const,
|
||||
},
|
||||
{
|
||||
label: "리뷰 대기",
|
||||
value: dashboard?.pending_reviews.length ?? 0,
|
||||
hint: "교수자 확인",
|
||||
icon: "review" as const,
|
||||
label: "전체 학생",
|
||||
value: dashboard?.total_learners ?? 0,
|
||||
hint: "담당 코호트",
|
||||
icon: "users" as const,
|
||||
},
|
||||
{
|
||||
label: "활성 세션",
|
||||
value: dashboard?.active_sessions ?? 0,
|
||||
hint: "진행 중",
|
||||
icon: "play" as const,
|
||||
},
|
||||
],
|
||||
[dashboard],
|
||||
[dashboard, personaReviews.length],
|
||||
);
|
||||
const pendingCount = dashboard?.pending_reviews.length ?? 0;
|
||||
const hasPending = pendingCount > 0;
|
||||
|
|
@ -430,7 +436,7 @@ export default function Professor() {
|
|||
|
||||
<section className="pf-workspace">
|
||||
<div className="pf-queue-stack">
|
||||
<section className="pf-section">
|
||||
<section className="pf-section pf-section--draft">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>페르소나 저작</Kicker>
|
||||
|
|
@ -494,7 +500,7 @@ export default function Professor() {
|
|||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<section className="pf-section pf-section--persona">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>페르소나 검수</Kicker>
|
||||
|
|
@ -591,7 +597,7 @@ export default function Professor() {
|
|||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<section className="pf-section pf-section--safety">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>위기 알림</Kicker>
|
||||
|
|
@ -618,7 +624,7 @@ export default function Professor() {
|
|||
</Card>
|
||||
</section>
|
||||
|
||||
<section className="pf-section">
|
||||
<section className="pf-section pf-section--pending">
|
||||
<div className="pf-section__head">
|
||||
<div>
|
||||
<Kicker>검토 대기</Kicker>
|
||||
|
|
@ -831,11 +837,11 @@ function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
|
|||
|
||||
const PF_CSS = `
|
||||
.pf-root{
|
||||
max-width:var(--maxw);
|
||||
max-width:1280px;
|
||||
margin:0 auto;
|
||||
display:flex;
|
||||
flex-direction:column;
|
||||
gap:18px;
|
||||
gap:16px;
|
||||
}
|
||||
.pf-head{
|
||||
display:flex;
|
||||
|
|
@ -853,19 +859,20 @@ const PF_CSS = `
|
|||
}
|
||||
.pf-head p{
|
||||
margin:6px 0 0;
|
||||
max-width:680px;
|
||||
max-width:760px;
|
||||
color:var(--text-body);
|
||||
font-size:var(--fs-xs);
|
||||
line-height:1.55;
|
||||
}
|
||||
.pf-signal-strip{
|
||||
order:1;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1.45fr) minmax(300px,.75fr);
|
||||
gap:12px;
|
||||
grid-template-columns:1fr;
|
||||
gap:0;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-triage{
|
||||
display:grid;
|
||||
display:none;
|
||||
grid-template-columns:auto minmax(0,1fr) auto;
|
||||
align-items:center;
|
||||
gap:12px;
|
||||
|
|
@ -933,7 +940,7 @@ const PF_CSS = `
|
|||
}
|
||||
.pf-kpis{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(3,minmax(0,1fr));
|
||||
grid-template-columns:repeat(6,minmax(0,1fr));
|
||||
border:1px solid var(--hair);
|
||||
border-radius:var(--radius);
|
||||
overflow:hidden;
|
||||
|
|
@ -943,13 +950,13 @@ const PF_CSS = `
|
|||
.pf-kpi{
|
||||
position:relative;
|
||||
min-width:0;
|
||||
padding:11px 12px;
|
||||
padding:14px 16px;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(0,1fr) auto;
|
||||
gap:4px 10px;
|
||||
}
|
||||
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+4){border-top:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+4){border-top:0;}
|
||||
.pf-kpi__ic{
|
||||
grid-column:2;
|
||||
grid-row:1 / span 3;
|
||||
|
|
@ -971,7 +978,7 @@ const PF_CSS = `
|
|||
display:block;
|
||||
color:var(--text-strong);
|
||||
font-family:var(--font-num);
|
||||
font-size:23px;
|
||||
font-size:24px;
|
||||
line-height:1;
|
||||
}
|
||||
.pf-kpi small{
|
||||
|
|
@ -980,8 +987,9 @@ const PF_CSS = `
|
|||
line-height:1.35;
|
||||
}
|
||||
.pf-workspace{
|
||||
order:2;
|
||||
display:grid;
|
||||
grid-template-columns:minmax(330px,380px) minmax(0,1fr);
|
||||
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
|
||||
align-items:start;
|
||||
gap:14px;
|
||||
min-width:0;
|
||||
|
|
@ -1073,8 +1081,13 @@ const PF_CSS = `
|
|||
min-width:84px;
|
||||
}
|
||||
.pf-section--growth{
|
||||
order:3;
|
||||
min-width:0;
|
||||
}
|
||||
.pf-section--pending{order:1;}
|
||||
.pf-section--persona{order:2;}
|
||||
.pf-section--safety{order:3;}
|
||||
.pf-section--draft{order:4;}
|
||||
.pf-growth-panel{
|
||||
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
|
||||
}
|
||||
|
|
@ -1576,11 +1589,11 @@ const PF_CSS = `
|
|||
.pf-session__meta{justify-content:flex-start;}
|
||||
}
|
||||
@media (max-width:520px){
|
||||
.pf-kpis{grid-template-columns:1fr;}
|
||||
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
|
||||
.pf-kpi,
|
||||
.pf-kpi:nth-child(even),
|
||||
.pf-kpi + .pf-kpi{border-left:0;}
|
||||
.pf-kpi + .pf-kpi{border-top:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
|
||||
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
|
||||
.pf-persona__actions{
|
||||
display:grid;
|
||||
grid-template-columns:repeat(2,minmax(0,1fr));
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@ import {
|
|||
type SessionDetailResponse,
|
||||
type SessionStage,
|
||||
} from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
|
||||
import { SlideToEnd } from "./session/SlideToEnd";
|
||||
import "./session/session.css";
|
||||
|
|
@ -377,6 +378,7 @@ function expressionForSession({
|
|||
export default function Session() {
|
||||
const { sessionId: routeId } = useParams<{ sessionId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user, acceptConsent } = useAuth();
|
||||
|
||||
const routeParam = routeId ?? "";
|
||||
const routeIsSessionId = useMemo(() => looksLikeSessionId(routeParam), [routeParam]);
|
||||
|
|
@ -400,6 +402,8 @@ export default function Session() {
|
|||
const [liveSessionId, setLiveSessionId] = useState<string | null>(null);
|
||||
const [starting, setStarting] = useState(false);
|
||||
const [startError, setStartError] = useState<string | null>(null);
|
||||
const [consentChecked, setConsentChecked] = useState(false);
|
||||
const [consentBusy, setConsentBusy] = useState(false);
|
||||
|
||||
// ── 회기/대화 상태 ──
|
||||
const [stage, setStage] = useState<SessionStage>("라포");
|
||||
|
|
@ -656,13 +660,34 @@ export default function Session() {
|
|||
);
|
||||
}
|
||||
});
|
||||
} catch {
|
||||
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
||||
} catch (err) {
|
||||
if (err instanceof ApiError && err.detail === "consent_required") {
|
||||
setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다.");
|
||||
} else {
|
||||
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
||||
}
|
||||
} finally {
|
||||
setStarting(false);
|
||||
}
|
||||
}, [personaCode, personaSummary, pushSignal]);
|
||||
|
||||
const handleAcceptConsent = useCallback(async () => {
|
||||
if (!consentChecked) {
|
||||
setStartError("동의 항목을 확인해야 회기를 시작할 수 있습니다.");
|
||||
return;
|
||||
}
|
||||
setConsentBusy(true);
|
||||
setStartError(null);
|
||||
try {
|
||||
await acceptConsent();
|
||||
pushSignal("pos", "동의 확인");
|
||||
} catch {
|
||||
setStartError("동의 상태를 저장하지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
||||
} finally {
|
||||
setConsentBusy(false);
|
||||
}
|
||||
}, [acceptConsent, consentChecked, pushSignal]);
|
||||
|
||||
const appendServerClientReply = useCallback(
|
||||
(replyText: string | null, at = elapsed) => {
|
||||
if (!replyText) {
|
||||
|
|
@ -1256,10 +1281,13 @@ export default function Session() {
|
|||
sending;
|
||||
const elapsedLabel = formatElapsed(elapsed);
|
||||
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
|
||||
const canStartSession = personaLoadState === "ready" && personaIsUsable;
|
||||
const consentRequired = user?.role === "learner" && user.consentAt == null;
|
||||
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
|
||||
const prestartTitle = canStartSession
|
||||
? `${clientName}님과의 회기를 시작할까요?`
|
||||
: personaLoadState === "ready" && personaSummary
|
||||
: consentRequired
|
||||
? "동의 확인 후 회기를 시작할 수 있습니다."
|
||||
: personaLoadState === "ready" && personaSummary
|
||||
? "이 내담자는 현재 연습에 사용할 수 없습니다."
|
||||
: "연습 대상 정보를 확인하고 있습니다.";
|
||||
const personaStatusMessage =
|
||||
|
|
@ -1371,6 +1399,29 @@ export default function Session() {
|
|||
<p className="sx-prestart__note">{personaStatusMessage}</p>
|
||||
) : null}
|
||||
{startError ? <p className="sx-prestart__err">{startError}</p> : null}
|
||||
{consentRequired ? (
|
||||
<div className="sx-consent">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={consentChecked}
|
||||
onChange={(event) => setConsentChecked(event.currentTarget.checked)}
|
||||
/>
|
||||
<span>
|
||||
상담 연습 기록 저장, 개인정보 마스킹 후 평가 AI 처리, 회기 리뷰 생성을
|
||||
확인합니다.
|
||||
</span>
|
||||
</label>
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleAcceptConsent}
|
||||
disabled={!consentChecked || consentBusy}
|
||||
leading={<Icon name="check" size={15} />}
|
||||
>
|
||||
{consentBusy ? "저장 중" : "동의 저장"}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="sx-prestart__actions">
|
||||
<Button
|
||||
size="lg"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import {
|
|||
Input,
|
||||
Kicker,
|
||||
Panel,
|
||||
SectionHead,
|
||||
type IconName,
|
||||
} from "../components/ui";
|
||||
import { roleLabel, useAuth } from "../lib/auth";
|
||||
|
|
@ -158,6 +157,13 @@ export default function Settings() {
|
|||
const affiliationDirtyRef = useRef(false);
|
||||
const engineConfigRef = useRef<AdminEngineConfigResponse | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
document.body.setAttribute("data-page", "settings");
|
||||
return () => {
|
||||
document.body.removeAttribute("data-page");
|
||||
};
|
||||
}, []);
|
||||
|
||||
const flashSaved = (key: string) => {
|
||||
setSavedKey(key);
|
||||
if (savedTimer.current) window.clearTimeout(savedTimer.current);
|
||||
|
|
@ -365,23 +371,7 @@ export default function Settings() {
|
|||
const engineServiceStatus = engineService?.status ?? "degraded";
|
||||
|
||||
return (
|
||||
<AppShell contextLabel="설정">
|
||||
<div className="vg-set__mast">
|
||||
<SectionHead
|
||||
kicker="설정"
|
||||
title="계정과 학습 환경을 관리합니다"
|
||||
desc="계정 표시 정보, 음성, 알림, 운영 설정을 한 곳에서 관리합니다."
|
||||
/>
|
||||
<div className="vg-set__mast-meta" aria-label="설정 상태">
|
||||
<span className="vg-set__pill">{roleLabel(role)}</span>
|
||||
{isAdmin ? (
|
||||
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
|
||||
AI {healthStatusLabel(engineService?.status)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AppShell contextLabel="설정" hideNav hideTopbar bleed>
|
||||
{error ? (
|
||||
<div className="vg-set__callout vg-set__callout--warn" role="alert">
|
||||
<span className="vg-set__callout-ico">
|
||||
|
|
@ -393,6 +383,16 @@ export default function Settings() {
|
|||
|
||||
<div className="vg-set" aria-busy={loading}>
|
||||
<aside className="vg-set__rail">
|
||||
<div className="vg-set__rail-title">
|
||||
<span className="vg-set__rail-mark" aria-hidden="true">
|
||||
<Icon name="settings" size={24} strokeWidth={2} />
|
||||
</span>
|
||||
<div>
|
||||
<h1>설정</h1>
|
||||
<p>계정과 학습 환경</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="vg-set__rail-card" aria-label="계정 요약">
|
||||
<div className="vg-set__rail-avatar" aria-hidden="true">
|
||||
{initials}
|
||||
|
|
@ -424,6 +424,15 @@ export default function Settings() {
|
|||
</button>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="vg-set__rail-status" aria-label="설정 상태">
|
||||
<span className="vg-set__pill">{roleLabel(role)}</span>
|
||||
{isAdmin ? (
|
||||
<span className={`vg-set__pill vg-set__pill--${engineServiceStatus}`}>
|
||||
AI {healthStatusLabel(engineService?.status)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div className="vg-set__forms">
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@
|
|||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
inset: -90px -170px auto auto;
|
||||
inset: -90px 0 auto auto;
|
||||
width: min(620px, 56vw);
|
||||
height: 440px;
|
||||
background: var(--asset-warm-elements) center / cover no-repeat;
|
||||
|
|
@ -128,16 +128,15 @@
|
|||
.sr-cols {
|
||||
min-width: 0;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.34fr) minmax(320px, 0.66fr);
|
||||
grid-template-columns: minmax(230px, 0.62fr) minmax(0, 1.22fr) minmax(280px, 0.68fr);
|
||||
grid-template-areas:
|
||||
"overview rubric"
|
||||
"chart rubric"
|
||||
"flow good"
|
||||
"transcript growth"
|
||||
"transcript worksheet"
|
||||
"transcript feedback"
|
||||
"transcript session";
|
||||
gap: var(--sp-5);
|
||||
"overview chart rubric"
|
||||
"overview flow good"
|
||||
"transcript transcript growth"
|
||||
"transcript transcript feedback"
|
||||
"worksheet worksheet worksheet"
|
||||
"session session session";
|
||||
gap: var(--sp-4);
|
||||
align-items: start;
|
||||
margin-top: 0;
|
||||
}
|
||||
|
|
@ -150,6 +149,9 @@
|
|||
min-width: 0;
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
align-self: start;
|
||||
position: sticky;
|
||||
top: calc(var(--topbar-h) + var(--sp-4));
|
||||
}
|
||||
.sr-feedback {
|
||||
grid-area: feedback;
|
||||
|
|
@ -181,6 +183,7 @@
|
|||
.sr-card {
|
||||
min-width: 0;
|
||||
box-shadow: var(--shadow-sm);
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
.sr-card--side {
|
||||
align-self: start;
|
||||
|
|
@ -199,12 +202,19 @@
|
|||
letter-spacing: 0;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sr-overview .sr-summary {
|
||||
font-size: clamp(18px, 1.26vw, 21px);
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 12;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
.sr-summary .sr-hl {
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
.sr-readiness {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--hair);
|
||||
|
|
@ -229,11 +239,13 @@
|
|||
font-weight: 650;
|
||||
}
|
||||
.sr-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.sr-actions .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.sr-empty {
|
||||
max-width: none;
|
||||
|
|
@ -703,6 +715,10 @@
|
|||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-4);
|
||||
max-height: 620px;
|
||||
overflow: auto;
|
||||
padding-right: 4px;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
.sr-ws-section {
|
||||
min-width: 0;
|
||||
|
|
@ -961,11 +977,27 @@
|
|||
"chart flow"
|
||||
"rubric rubric"
|
||||
"good growth"
|
||||
"worksheet worksheet"
|
||||
"feedback feedback"
|
||||
"transcript transcript"
|
||||
"feedback feedback"
|
||||
"worksheet worksheet"
|
||||
"session session";
|
||||
}
|
||||
.sr-overview {
|
||||
position: static;
|
||||
}
|
||||
.sr-readiness {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.sr-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.sr-actions .vg-btn {
|
||||
width: auto;
|
||||
}
|
||||
.sr-worksheet {
|
||||
max-height: 520px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 780px) {
|
||||
|
|
@ -992,9 +1024,9 @@
|
|||
"rubric"
|
||||
"good"
|
||||
"growth"
|
||||
"worksheet"
|
||||
"feedback"
|
||||
"transcript"
|
||||
"feedback"
|
||||
"worksheet"
|
||||
"session";
|
||||
gap: var(--sp-4);
|
||||
}
|
||||
|
|
@ -1002,6 +1034,9 @@
|
|||
font-size: 20px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.sr-overview .sr-summary {
|
||||
-webkit-line-clamp: 8;
|
||||
}
|
||||
.sr-readiness {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
|
|
@ -1012,6 +1047,9 @@
|
|||
.sr-actions .vg-btn {
|
||||
flex: 1 1 150px;
|
||||
}
|
||||
.sr-worksheet {
|
||||
max-height: 460px;
|
||||
}
|
||||
.sr-chart__plot {
|
||||
height: 156px;
|
||||
}
|
||||
|
|
@ -1049,6 +1087,9 @@
|
|||
.sr-summary {
|
||||
font-size: 18px;
|
||||
}
|
||||
.sr-overview .sr-summary {
|
||||
-webkit-line-clamp: 7;
|
||||
}
|
||||
.sr-readiness {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1505,6 +1505,33 @@
|
|||
font-size: var(--fs-sm);
|
||||
color: var(--crit-text);
|
||||
}
|
||||
.sx-consent {
|
||||
width: min(100%, 520px);
|
||||
display: grid;
|
||||
gap: var(--sp-3);
|
||||
padding: var(--sp-4);
|
||||
border: 1px solid rgba(132, 203, 179, 0.28);
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(30, 86, 77, 0.22);
|
||||
}
|
||||
.sx-consent label {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--sp-3);
|
||||
color: rgba(238, 244, 242, 0.84);
|
||||
font-size: var(--fs-sm);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.sx-consent input {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
margin-top: 2px;
|
||||
accent-color: #6fc6a8;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.sx-consent .vg-btn {
|
||||
justify-self: start;
|
||||
}
|
||||
.sx-prestart__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
|
|||
|
|
@ -6,6 +6,45 @@
|
|||
===================================================================== */
|
||||
|
||||
/* ── 레이아웃: 좌측 섹션 내비(sticky) + 우측 폼 ── */
|
||||
body[data-page="settings"] .vg-shell {
|
||||
background:
|
||||
linear-gradient(135deg, rgba(251, 250, 248, 0.96), rgba(244, 242, 238, 0.92)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
}
|
||||
body[data-page="settings"] .vg-topbar {
|
||||
background: rgba(251, 250, 248, 0.94);
|
||||
border-bottom-color: var(--hair);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-page="settings"] .vg-topbar__wm,
|
||||
body[data-page="settings"] .vg-topbar__wm .v,
|
||||
body[data-page="settings"] .vg-topbar__role,
|
||||
body[data-page="settings"] .vg-topbar__uname,
|
||||
body[data-page="settings"] .vg-iconbtn,
|
||||
body[data-page="settings"] .vg-topbar__mark {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-page="settings"] .vg-topbar__brand svg {
|
||||
color: var(--accent);
|
||||
}
|
||||
body[data-page="settings"] .vg-topbar__role {
|
||||
border-left-color: var(--hair);
|
||||
}
|
||||
body[data-page="settings"] .vg-topbar__user {
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
body[data-page="settings"] .vg-topbar__avatar {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
body[data-page="settings"] .vg-iconbtn:hover {
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-page="settings"] .vg-main {
|
||||
padding: var(--sp-6) var(--sp-5) var(--sp-8);
|
||||
}
|
||||
|
||||
.vg-set {
|
||||
display: grid;
|
||||
grid-template-columns: 204px minmax(0, 780px);
|
||||
|
|
@ -1175,3 +1214,326 @@
|
|||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
/* Image-aligned workspace: generated settings concept -> production controls. */
|
||||
body[data-page="settings"] .vg-shell {
|
||||
min-height: 100dvh;
|
||||
background:
|
||||
radial-gradient(circle at 8% 0%, rgba(222, 238, 234, 0.62), transparent 34%),
|
||||
linear-gradient(135deg, rgba(252, 250, 247, 0.97), rgba(244, 242, 237, 0.94)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
}
|
||||
body[data-page="settings"] .vg-main {
|
||||
padding: 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-main__inner {
|
||||
max-width: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__callout {
|
||||
max-width: 1120px;
|
||||
margin: var(--sp-5) auto 0;
|
||||
}
|
||||
|
||||
body[data-page="settings"] .vg-set {
|
||||
grid-template-columns: 244px minmax(0, 820px);
|
||||
justify-content: center;
|
||||
align-items: start;
|
||||
gap: 28px;
|
||||
max-width: 1168px;
|
||||
min-height: 100dvh;
|
||||
padding: 30px 24px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail {
|
||||
position: sticky;
|
||||
top: 24px;
|
||||
min-height: calc(100dvh - 60px);
|
||||
padding: 24px 16px 18px;
|
||||
border: 1px solid rgba(38, 101, 92, 0.14);
|
||||
border-radius: var(--radius);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(238, 248, 245, 0.92), rgba(248, 250, 248, 0.72)),
|
||||
rgba(255, 255, 255, 0.82);
|
||||
box-shadow: 0 24px 58px rgba(37, 45, 42, 0.08);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
padding: 2px 6px 20px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-mark {
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
flex: none;
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title h1 {
|
||||
margin: 0;
|
||||
color: var(--text-strong);
|
||||
font-size: 24px;
|
||||
line-height: 1.15;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title p {
|
||||
margin: 3px 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.35;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-card {
|
||||
display: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav {
|
||||
position: static;
|
||||
padding: 0;
|
||||
gap: 10px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-kicker {
|
||||
display: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item {
|
||||
min-height: 49px;
|
||||
padding: 0 14px;
|
||||
border-radius: var(--radius);
|
||||
color: #1f5f58;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item:hover {
|
||||
background: rgba(24, 121, 103, 0.09);
|
||||
color: #124d47;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item.is-active {
|
||||
background: linear-gradient(135deg, #187967, #106759);
|
||||
color: #ffffff;
|
||||
box-shadow: 0 12px 24px rgba(16, 103, 89, 0.22);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item.is-active svg {
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-status {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: auto;
|
||||
padding: 18px 4px 0;
|
||||
}
|
||||
|
||||
body[data-page="settings"] .vg-set__forms {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 14px;
|
||||
max-width: 820px;
|
||||
}
|
||||
body[data-page="settings"] #set-account {
|
||||
order: 1;
|
||||
}
|
||||
body[data-page="settings"] #set-engine {
|
||||
order: 2;
|
||||
}
|
||||
body[data-page="settings"] #set-appearance {
|
||||
order: 3;
|
||||
}
|
||||
body[data-page="settings"] #set-voice {
|
||||
order: 4;
|
||||
}
|
||||
body[data-page="settings"] #set-notify {
|
||||
order: 5;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group {
|
||||
padding: 18px;
|
||||
border: 1px solid rgba(37, 45, 42, 0.12);
|
||||
border-radius: var(--radius);
|
||||
background: rgba(255, 255, 255, 0.93);
|
||||
box-shadow: 0 18px 38px rgba(38, 45, 43, 0.07);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group-head {
|
||||
align-items: flex-start;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group-title {
|
||||
font-size: 18px;
|
||||
font-weight: 750;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group-desc {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__profile {
|
||||
margin-bottom: 12px;
|
||||
padding: 13px;
|
||||
background: rgba(244, 241, 236, 0.78);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__avatar {
|
||||
background: linear-gradient(135deg, #1d8774, #0f6559);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__field-grid--account,
|
||||
body[data-page="settings"] .vg-set__field-grid--engine {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
body[data-page="settings"] .vg-set__field--wide {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__field,
|
||||
body[data-page="settings"] .vg-set__control-block,
|
||||
body[data-page="settings"] .vg-set__opt,
|
||||
body[data-page="settings"] .vg-set__range-row {
|
||||
border-color: rgba(37, 45, 42, 0.12);
|
||||
background: rgba(255, 255, 255, 0.86);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__engine-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 10px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__seg {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
background: rgba(244, 241, 236, 0.82);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__seg-btn.is-active {
|
||||
background: #187967;
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ops {
|
||||
width: min(300px, 100%);
|
||||
min-height: 54px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__voicelist {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
body[data-page="settings"] .vg-set__voice {
|
||||
min-height: 72px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__foot {
|
||||
margin-top: 12px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__pill {
|
||||
background: rgba(255, 255, 255, 0.68);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
body[data-page="settings"] .vg-set {
|
||||
grid-template-columns: 86px minmax(0, 1fr);
|
||||
gap: 18px;
|
||||
max-width: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail {
|
||||
padding: 18px 10px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title {
|
||||
justify-content: center;
|
||||
padding: 0 0 16px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title div,
|
||||
body[data-page="settings"] .vg-set__nav-item span,
|
||||
body[data-page="settings"] .vg-set__rail-status {
|
||||
display: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item {
|
||||
justify-content: center;
|
||||
min-height: 48px;
|
||||
padding: 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__forms {
|
||||
max-width: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body[data-page="settings"] .vg-set__callout {
|
||||
margin: 12px 12px 0;
|
||||
}
|
||||
body[data-page="settings"] .vg-set {
|
||||
display: block;
|
||||
min-height: 100dvh;
|
||||
padding: 0 16px 28px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
min-height: auto;
|
||||
margin: 0 -16px 12px;
|
||||
padding: 13px 16px 10px;
|
||||
border: 0;
|
||||
border-bottom: 1px solid rgba(37, 45, 42, 0.1);
|
||||
border-radius: 0;
|
||||
background: rgba(252, 250, 247, 0.96);
|
||||
box-shadow: 0 12px 26px rgba(37, 45, 42, 0.08);
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title {
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
padding: 0 0 10px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title div {
|
||||
display: block;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title h1 {
|
||||
font-size: 16px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-title p {
|
||||
display: none;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__rail-mark {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 2px;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item {
|
||||
flex: 0 0 auto;
|
||||
min-height: 34px;
|
||||
gap: 5px;
|
||||
padding: 0 7px;
|
||||
font-size: 11px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__nav-item span {
|
||||
display: inline;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__forms {
|
||||
gap: 10px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group {
|
||||
padding: 13px;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__field-grid--account,
|
||||
body[data-page="settings"] .vg-set__field-grid--engine,
|
||||
body[data-page="settings"] .vg-set__seg,
|
||||
body[data-page="settings"] .vg-set__voicelist {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__field--wide {
|
||||
grid-column: auto;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__group-head--split {
|
||||
flex-direction: column;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__ops {
|
||||
width: 100%;
|
||||
}
|
||||
body[data-page="settings"] .vg-set__voice {
|
||||
min-height: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -113,6 +113,7 @@
|
|||
|
||||
/* ── 래스터 디자인 요소 ── */
|
||||
--asset-warm-elements: url("/design-elements/clinical-paper-ambient.png");
|
||||
--asset-login-room: url("/design-elements/login-counseling-room.png");
|
||||
|
||||
/* ── 모션 §3.9 ── */
|
||||
--ease-out: cubic-bezier(0.22, 0.61, 0.36, 1);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Vignette Handoff
|
||||
|
||||
> Updated: 2026-06-27 16:16 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
||||
> Updated: 2026-06-27 17:06 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
|
||||
|
||||
## 현재 상태
|
||||
|
||||
|
|
@ -10,25 +10,26 @@
|
|||
- 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099`
|
||||
- 공개 웹: `https://vignette.chanpaca.net`
|
||||
- 공개 API: `https://api-vignette.chanpaca.net`
|
||||
- 앱 배포 소스 commit: `6bd91b0d` (`현재 작업 상태 저장`)
|
||||
- 최신 Cloudflare Pages production deploy: `2a58ca49`, source `6bd91b0d` + dirty worktree, branch `main`
|
||||
- 직전 기록된 앱 배포 소스 commit: `6bd91b0d` (`현재 작업 상태 저장`)
|
||||
- 직전 기록된 Cloudflare Pages production deploy: `2a58ca49`, source `6bd91b0d` + dirty worktree, branch `main`. 최신 배포 ID는 `wrangler pages deploy` 실행 로그와 최종 작업 보고를 기준으로 확인한다.
|
||||
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
|
||||
- 최신 백엔드 회귀: `python -m pytest app/ -q` → `122 passed`
|
||||
- 최신 백엔드 회귀: `python -m pytest app/ -q` → `125 passed`
|
||||
- X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부.
|
||||
- C1 사례개념화 워크시트 1차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 read-only 카드가 축어록 근거 기반 초안을 제공한다. 편집·DB 저장, 임상 루브릭, AI 추출/채점은 후속.
|
||||
- C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속.
|
||||
- H2 평가 정규화: 턴 평가의 대안발화도 `app.alternative_utterance`에 적재하고 리뷰 hydrate 시 `alternative_utterances`로 복원한다.
|
||||
- X2 예산 경고/저비용 평가 라우팅: `ADMIN_USAGE_BUDGET_USD`가 0보다 크면 `/admin/usage`가 budget 상태(ok/warn/exceeded)를 반환하고 `/admin`이 예산 배너를 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL`을 설정하면 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달한다. 비우면 기존 gateway default 라우팅을 유지한다.
|
||||
- H4 LLM call audit: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다.
|
||||
- H4 LLM call audit + 동의 게이트: 상담 생성(generate/stream)과 fast/deep 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다. 이번 패스에서 `app_user.consent_at` 기반 learner 동의 수락/철회 API와 세션 시작/voice dev persona 시작 하드게이트를 추가했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속.
|
||||
- RAG warm 동시성: E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 지연 로드되어 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py` embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py` warm task를 semaphore 1개로 제한했다. 최신 검증: `layout-visual-gate + session-layout` 11 passed.
|
||||
- M3 인증 claim 1차: Google/SAML/dev-login이 설정 기반 cohort map과 SAML cohort claim을 `cohort_ids`로 넘기고, 관리 사용자 `external_id`는 provider subject 기반(`google:`/`saml:`/`dev:`)으로 저장한다. 운영 SAML 서명검증·기관 claim schema·deprovisioning audit은 후속.
|
||||
- Google OAuth 진단 1차: provider callback error는 `access_denied`/`provider_error`로 분리하고, 로그인 화면은 실패 reason code를 함께 표시한다. 실제 Google 계정 완료 proof는 아직 owner 로그인/storageState가 필요하다.
|
||||
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
|
||||
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
|
||||
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010`도 `ENVIRONMENT=dev`로 떠 있다.
|
||||
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다.
|
||||
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-27 17:00 KST 재확인: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true.
|
||||
- `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료.
|
||||
- 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다.
|
||||
- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이며 dev-login까지 검증됐다.
|
||||
- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite web, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 `scripts/start-tailscale-runtime.ps1` 재기동 후 `login` 200, `/api/health` dev/db/engine true, direct Google start → `local_oauth_unavailable`로 재검증됐다.
|
||||
- `scripts/dev-up.ps1 -NoGateway -NoWeb`는 이제 gateway/web stale 정리를 건너뛰고 지정 `-ApiPort`의 API만 재기동한다. 검증 당시 8000 API-only 재기동 후 8001/8010/5173/9099/20241 listener가 보존됐다.
|
||||
- Docker `vignette-dev-db`는 실행 중이고 DB는 accepting connections다. 단, 기존 컨테이너라 healthcheck가 없고 `POSTGRES_USER=vignette` 기반이다. `vignette_app` role은 NOBYPASSRLS로 존재하지만 현재 API startup DDL이 owner 권한을 요구하므로 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행한다.
|
||||
- 공개 런타임 재기동 스크립트:
|
||||
|
|
|
|||
|
|
@ -450,6 +450,9 @@
|
|||
<p class="dg-note">2차 적용(2026-06-27): 생성된 관리자/설정/리뷰 시안 기준으로 admin 역할의 상단바·사이드바를 어두운 운영 콘솔 크롬으로 전환했고, 관리자 사용자 관리 행을 조밀한 운영 테이블 밀도로 낮췄다. 회기 리뷰는 헤더·인사이트 카드·피드백 스테이지에 따뜻한 래스터 질감과 낮은 채도 틴트를 적용했다.</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">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><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</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>
|
||||
</article>
|
||||
<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>
|
||||
<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>
|
||||
<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행 증가했다. 이번 패스에서 <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 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>
|
||||
|
|
@ -605,7 +608,7 @@
|
|||
</article>
|
||||
</div>
|
||||
<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>핵심 축어록 2단 표는 PDF 시각 판독이 1차 근거(txt 일부 누락), OCR 잔재 존재.</li><li>doc4 κ/ICC·환각률은 신청서 본문 미명시 — 계약 확정 지표로 단정 금지.</li><li>'현재상태'는 grep/코드 사실 기반. C1~C3·H2·H3은 직접 재확인(✓), 그 외는 착수 전 코드 1차 재확인 권고.</li></ul></div>
|
||||
</div>
|
||||
|
|
@ -641,7 +644,7 @@
|
|||
</article>
|
||||
<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>
|
||||
<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>
|
||||
</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>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>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>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>
|
||||
|
|
@ -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 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/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>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>
|
||||
|
|
|
|||
|
|
@ -62,7 +62,7 @@
|
|||
| **H1** | 계약 평가 KPI(자기효능감·기술숙련도·수련만족도 사전사후) 수집·집계 전무 | `자기효능감/사전사후/수련만족/실험통제군` grep 0건. Phase3 KPI도 report shape만, 계산 코드 0줄. (분석) | 3척도 pre-post 폼·실험/통제군 배정·자동누적 대시보드·추이 시각화·검정 계산 코드. (κ/ICC·환각률은 doc4 미명시 → 평가설계 확정.) | doc4(20명 실험/통제군·단회기 50분·3척도 pre-post) |
|
||||
| **H2** | 턴별 회기 리뷰 fast-loop 1차 가동·골든셋/2열 UI 잔여 | `make_eval_hook`이 submit/voice 생성 경로에 주입되고, stream은 `_evaluate_stream_turn`으로 fast-loop 평가를 붙인다. 결과는 `feedback_scores`, `alternative_utterance` 등 정규화 테이블에 적재·hydrate된다. | 회기리뷰 UI 좌(축어록 타임라인+비언어)/우(기법·적절성·대안반응·이론) 2열 고도화, 원천 축어록 few-shot 골든셋 적재. | doc2·doc5(골드 포맷) |
|
||||
| **H3** | 임상팀 콘텐츠 입력 경로(페르소나 저작 CRUD) 부재 + P4~P7 미적재 | personas 라우트에 검수 승인/반려만, draft 생성·편집 API 없음. `persona_repository.py`는 in-code `SEED_PERSONAS`(P1~P3)만 materialize, 외부 JSON 미로드 ✓. | 페르소나 저작 CRUD(draft→review) + P4~P7 적재, 루브릭·이론 콘텐츠를 임상팀 편집 가능 데이터로 외부화. | doc3(R&R)·doc4(페르소나=전문가 산출물) |
|
||||
| **H4** | PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여 | Presidio `language='en'` 고정이라 이름/기관명은 NER 보강이 필요하지만, 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `consent_at` 컬럼만 있고 동의 수집/게이트/철회 엔드포인트는 아직 없다. | 한국어 이름/기관 NER 추가, 미성년/guardian 동의 수집·게이트·철회. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) |
|
||||
| **H4** | PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여 | Presidio `language='en'` 고정이라 이름/기관명은 NER 보강이 필요하지만, 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user.consent_at` 기반 학습자 동의 수락/철회 엔드포인트와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 프론트 세션 시작 전 동의 UI와 E2E 동의 seed를 연결했다. | 한국어 이름/기관 NER 추가, 미성년/guardian 및 법무 검토가 필요한 서명 동의서/개인정보 고지 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) |
|
||||
|
||||
### Medium+ (6) — M1~M3, X1~X2, L1
|
||||
|
||||
|
|
@ -90,7 +90,7 @@
|
|||
- **C3 1차 완료**: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
|
||||
- **H2 1차 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
|
||||
- **H3**: 페르소나 저작 CRUD(draft→review) + P4~P7 로드 경로.
|
||||
- **H4(부분)·X1·X2**: 마스킹 한국어 정규식 보강과 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, dry-run JSONL export·PII scan·IAA 계산 1차는 완료했다. `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고와 evaluator fast/deep 모델 override 1차는 완료했고 semantic cache·장기 한도 정책은 후속. M1/M2도 1차 구현 완료, provider 기반 비언어 감지와 case_digest/pinned_fact 실적재는 후속.
|
||||
- **H4(부분)·X1·X2**: 마스킹 한국어 정규식 보강, 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료했다. 한국어 이름/기관 NER와 guardian/legal 서명 evidence는 후속이다. dry-run JSONL export·PII scan·IAA 계산 1차도 완료했고 `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고와 evaluator fast/deep 모델 override 1차는 완료했고 semantic cache·장기 한도 정책은 후속. M1/M2도 1차 구현 완료, provider 기반 비언어 감지와 case_digest/pinned_fact 실적재는 후속.
|
||||
|
||||
### B. 소유자 결정 / 외부(임상팀·기관) 의존
|
||||
|
||||
|
|
|
|||
|
|
@ -75,5 +75,7 @@
|
|||
- M3 인증 claim 1차: Google/SAML/dev-login이 `AUTH_EMAIL_COHORT_MAP`·`AUTH_DOMAIN_COHORT_MAP` 및 SAML cohort claim을 `cohort_ids`로 전달하고, DB `app_user.external_id`는 provider subject(`google:`/`saml:`/`dev:`) 기반으로 저장한다. 운영 SAML 서명검증, 기관 claim schema/test tenant, deprovisioning audit은 후속. **검증: `pytest app/test_auth_providers.py -q` 22 passed, `pytest app/ -q` 119 passed.**
|
||||
- X2 예산 경고/저비용 평가 라우팅 1차: `ADMIN_USAGE_BUDGET_USD` 설정값을 기준으로 `GET /admin/usage`가 `budget.status=disabled|ok|warn|exceeded`, 사용률, 잔여 예산을 반환하고 관리자 콘솔이 예산 상태 배너를 표시한다. 80% 이상 warn, 100% 이상 exceeded. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL` 설정 시 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달하고, 비워두면 기존 gateway default 라우팅을 유지한다. semantic cache·장기 한도 정책은 후속. **검증: `pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q` 23 passed, `pytest app/ -q` 122 passed, `npm run check:api-types`, `npm run typecheck`.**
|
||||
- H4 LLM call audit 1차: 상담 생성(generate/stream)·fast-loop 평가·deep-loop 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않고 감사 실패는 상담 루프를 막지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다(session `2460d56c-a9cb-4a40-a175-9575d510a5e9`). 공개 Google OAuth 실제 `/turn` proof는 별도 B2 항목에 남긴다. **검증: `pytest app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py -q` 27 passed, `pytest app/ -q` 119 passed.**
|
||||
- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. **검증: `https://vignette.chanpaca.net/login` 200 + `index-BzQmv-jU.js`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, local/Tailnet Google direct는 각 origin의 `/login?oauth=local_oauth_unavailable`, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.**
|
||||
- H4 동의 게이트 1차: `app_user.consent_at`를 런타임 테이블 readiness와 `SessionUser`/`Principal`에 포함하고, learner 전용 `POST /auth/consent`·`DELETE /auth/consent`를 추가했다. 동의가 없는 learner는 `POST /sessions`와 voice dev persona 시작이 `consent_required`로 막히고, 프론트 세션 시작 전 동의 체크 UI가 먼저 저장하도록 안내한다. E2E dev-login helper는 세션 테스트 전에 동의를 seed한다. 한국어 이름/기관 NER, guardian/legal 서명 동의서 evidence, 공개 Google OAuth 실제 `/turn` proof는 후속. **검증: `pytest app/ -q` 125 passed, `pytest app/test_auth_providers.py app/test_session_turn_persistence.py app/test_persona_review.py app/test_voice_ws.py -q` 64 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, local `/sessions` no-consent 403 + `/auth/consent` 200.**
|
||||
- RAG warm 동시성 안정화: layout/session E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 여러 번 지연 로드되며 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py`에서 embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py`의 `_warm_rag_caches`를 semaphore 1개로 제한해 warm task가 상담 요청 경로를 막지 않도록 했다. **검증: `pytest app/ -q` 125 passed, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts --project=chromium-single-run --project=chromium-desktop --workers=1` 11 passed.**
|
||||
- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. 2026-06-27 모바일 502는 cloudflared 로그의 `127.0.0.1:8001` origin refused와 일치했다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. **검증: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, local/Tailnet Google direct는 각 origin의 `/login?oauth=local_oauth_unavailable`, `https://alpaca-home.taile93291.ts.net/login` 200 + `/api/health` dev/db/engine true, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.**
|
||||
- Docker dev DB 안전장치: `scripts/dev-up.ps1`가 `vignette-dev-db` healthcheck/pg_isready/role safety를 점검하고, 새 컨테이너 생성 시 `vignette_owner`와 앱 role을 분리해 healthcheck를 붙인다. `infra\.env` compose 필수값 누락도 사전 경고한다. `scripts/dev-down.ps1`은 기본 DB 보존, `-Db` 지정 시 컨테이너 중지로 정리했다. **검증: 현재 Docker DB accepting connections, `vignette_app:false:false`, 기존 컨테이너 healthcheck 없음. 추가 확인 결과 기존 DB는 `POSTGRES_USER=vignette` 기반이고 API startup DDL이 owner 권한을 요구해 런타임 app-role 전환은 마이그레이션 owner/런타임 role 분리 후 진행해야 한다.**
|
||||
|
|
|
|||
|
|
@ -76,8 +76,8 @@
|
|||
|
||||
### [high] H4. PII 마스킹 한국어 이름/기관 NER + 동의 게이트 잔여
|
||||
- **근거**: doc1/2/5(실명·날짜·장소·금액·미성년·자살시도 다수), doc4(IRB·개인정보)
|
||||
- **현재상태**(재검증): Presidio language='en' 고정으로 이름/기관명은 NER 보강이 필요하다. 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐고, 상담 생성(generate/stream)·fast/deep 평가 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. consent_at 컬럼만, 동의 수집/게이트/철회 엔드포인트 전무.
|
||||
- **권고**: 한국어 이름/기관 NER 추가, 미성년/guardian 동의 수집·하드게이트·철회. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트.
|
||||
- **현재상태**(재검증): Presidio language='en' 고정으로 이름/기관명은 NER 보강이 필요하다. 한국어 날짜·금액·행정구역 주소 정규식 폴백은 추가됐고, 상담 생성(generate/stream)·fast/deep 평가 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user.consent_at` 기반 학습자 동의 수락/철회 엔드포인트와 회기 시작·voice dev persona 시작 하드게이트를 추가했다. 프론트 세션 시작 전 동의 체크 UI와 E2E dev-login 동의 seed도 연결했다.
|
||||
- **권고**: 한국어 이름/기관 NER 추가, 미성년/guardian 및 법무 검토가 필요한 서명 동의서/개인정보 고지 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트.
|
||||
|
||||
### [medium] M1. 비언어/준언어 임상 이벤트 캡처·태깅 부재
|
||||
- **근거**: doc1/2/5(침묵 초·한숨·울음·떨림을 1급 단서)
|
||||
|
|
@ -115,7 +115,7 @@
|
|||
- C3 1차 완료: `theory_mode`가 세션·평가·생성 프롬프트까지 흐른다. 후속은 임상팀 CBT 체인·이론부합 루브릭·명시적 선택 UI.
|
||||
- H2 1차 완료: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 후속은 2열 리뷰 UI·골든셋.
|
||||
- H3: 페르소나 저작 CRUD + P4~P7 로드 경로.
|
||||
- H4(부분): 한국어 이름/기관 NER, 동의 게이트. LLM call audit 적재와 로컬 live row proof는 완료.
|
||||
- H4(부분): 한국어 이름/기관 NER, guardian/legal 서명 동의 evidence. LLM call audit 적재, 로컬 live row proof, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료.
|
||||
- X1: dry-run JSONL export·PII scan·IAA 계산 1차 완료. `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·budget alert와 evaluator 모델 override 1차는 완료했고 semantic cache·장기 한도 정책은 후속. M1/M2도 1차 구현 완료, provider 기반 비언어 감지와 case_digest/pinned_fact 실적재는 후속.
|
||||
|
||||
### B. 소유자 결정 / 외부(임상팀·기관) 의존
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@
|
|||
- gateway `127.0.0.1:9099`
|
||||
- Tailnet API `127.0.0.1:8010`
|
||||
- web `127.0.0.1:5173`
|
||||
- 2026-06-27 17:00 KST 재확인: Tailnet `GET /login`이 `403 Blocked request. This host ... is not allowed`를 반환하던 원인은 Vite가 stale env로 떠서 `VITE_ALLOWED_HOSTS=alpaca-home.taile93291.ts.net`를 반영하지 못한 상태였다. `scripts/start-tailscale-runtime.ps1 -ApiPort 8010 -WebPort 5173 -SkipServeUpdate` 재기동 후 `GET /login` 200, `GET /api/health` dev/db/engine true, direct Google start 302 `local_oauth_unavailable`로 복구 확인.
|
||||
|
||||
## 공개 vnet.18ka.net 상태
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue