diff --git a/.gitignore b/.gitignore
index 57449e9..7db8ef4 100644
--- a/.gitignore
+++ b/.gitignore
@@ -41,6 +41,8 @@ apps/api/uploads/
.idea/
.vscode/
postgres-data/
+.wrangler/
+public-runtime-watchdog.failcount
# 임시 실행/로그 (P1 빌드)
*.out
diff --git a/README.md b/README.md
index 957cf90..5c26d92 100644
--- a/README.md
+++ b/README.md
@@ -95,8 +95,8 @@ uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
```powershell
# 백엔드
-cd apps\api; python -m pytest app/ -q # 백엔드 기준선 178 pass
-python -m pytest engine_gateway/ -q # 현재 23 pass
+cd apps\api; python -m pytest app/ -q # 백엔드 기준선 400 pass
+python -m pytest engine_gateway/ -q # 현재 29 pass
# 프론트엔드
cd apps\web; npm run typecheck # tsc -b
npm run build # tsc -b && vite build
diff --git a/apps/api/app/auth_sessions.py b/apps/api/app/auth_sessions.py
index 8fd31b5..76575d8 100644
--- a/apps/api/app/auth_sessions.py
+++ b/apps/api/app/auth_sessions.py
@@ -20,6 +20,7 @@ from .auth_types import AccountStatus, RoleName
from .config import settings
from .db import get_pool
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
+from .runtime_schema import runtime_schema_bootstrap_required
from .services import notifications
logger = logging.getLogger(__name__)
@@ -191,7 +192,11 @@ def user_id_from_email(email: str) -> str:
def user_id_from_external_id(external_id: str) -> str:
- return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:user-external:{external_id.strip().lower()}"))
+ return str(
+ uuid.uuid5(
+ uuid.NAMESPACE_URL, f"vignette:user-external:{external_id.strip().lower()}"
+ )
+ )
def _normalize_email(email: str) -> str:
@@ -344,10 +349,11 @@ async def _runtime_tables_ready(conn) -> bool:
'persona_code',
'persona_display_name',
'persona_difficulty',
- 'prev_rapport_credit'
+ 'prev_rapport_credit',
+ 'session_goals'
)
GROUP BY table_schema, table_name
- HAVING count(*) = 5
+ HAVING count(*) = 6
) AS has_session_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
@@ -517,13 +523,9 @@ async def ensure_runtime_tables() -> None:
"""Ensure DB-backed auth/user runtime tables exist when a pool is available."""
pool = get_pool()
async with pool.acquire() as conn:
- if await _runtime_tables_ready(conn):
+ ready = await _runtime_tables_ready(conn)
+ if not runtime_schema_bootstrap_required("auth/user", ready=ready):
return
- if settings.environment != "dev":
- raise RuntimeError(
- "runtime DB schema is incomplete; run owner migration/init and "
- "scripts/check-deploy-preflight.py before starting the API"
- )
await conn.execute(
"""
DO $$
@@ -904,7 +906,8 @@ async def ensure_runtime_tables() -> None:
ADD COLUMN IF NOT EXISTS persona_code TEXT,
ADD COLUMN IF NOT EXISTS persona_display_name TEXT,
ADD COLUMN IF NOT EXISTS persona_difficulty TEXT,
- ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0
+ ADD COLUMN IF NOT EXISTS prev_rapport_credit REAL NOT NULL DEFAULT 0.0,
+ ADD COLUMN IF NOT EXISTS session_goals JSONB NOT NULL DEFAULT '[]'::jsonb
"""
)
await conn.execute(
@@ -1054,6 +1057,10 @@ async def ensure_runtime_tables() -> None:
)
"""
)
+ if not await _runtime_tables_ready(conn):
+ raise RuntimeError(
+ "auth/user development schema bootstrap did not satisfy readiness"
+ )
def _managed_user_from_row(row) -> ManagedUser:
@@ -1092,15 +1099,23 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
raise InactiveUserError("user is inactive")
if data.reactivate:
_inactive_emails.discard(normalized_email)
- uid = data.user_id or _email_index.get(normalized_email) or user_id_from_email(normalized_email)
+ uid = (
+ data.user_id
+ or _email_index.get(normalized_email)
+ or user_id_from_email(normalized_email)
+ )
current = _users.get(uid)
user = ManagedUser(
user_id=uid,
email=normalized_email,
- display_name=(data.display_name.strip() if data.display_name else "") or normalized_email,
+ display_name=(data.display_name.strip() if data.display_name else "")
+ or normalized_email,
role=data.role,
- admin_access=data.admin_access if data.admin_access is not None else (current.admin_access if current else False),
- account_status=data.account_status or (current.account_status if current else "approved"),
+ admin_access=data.admin_access
+ if data.admin_access is not None
+ else (current.admin_access if current else False),
+ account_status=data.account_status
+ or (current.account_status if current else "approved"),
cohort_ids=(
list(data.cohort_ids)
if data.cohort_ids is not None
@@ -1126,20 +1141,28 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
if data.grade_level is not None
else (current.grade_level if current else "")
),
- phone=data.phone.strip() if data.phone is not None else (current.phone if current else ""),
+ phone=data.phone.strip()
+ if data.phone is not None
+ else (current.phone if current else ""),
contact_address=(
data.contact_address.strip()
if data.contact_address is not None
else (current.contact_address if current else "")
),
- nickname=data.nickname.strip() if data.nickname is not None else (current.nickname if current else ""),
+ nickname=data.nickname.strip()
+ if data.nickname is not None
+ else (current.nickname if current else ""),
self_introduction=(
data.self_introduction.strip()
if data.self_introduction is not None
else (current.self_introduction if current else "")
),
- avatar_url=data.avatar_url.strip() if data.avatar_url is not None else (current.avatar_url if current else ""),
- consent_at=data.consent_at if data.consent_at is not None else (current.consent_at if current else None),
+ avatar_url=data.avatar_url.strip()
+ if data.avatar_url is not None
+ else (current.avatar_url if current else ""),
+ consent_at=data.consent_at
+ if data.consent_at is not None
+ else (current.consent_at if current else None),
profile_completed_at=(
data.profile_completed_at
if data.profile_completed_at is not None
@@ -1173,6 +1196,25 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
return user
+def _sync_managed_user_sessions(user: ManagedUser) -> None:
+ """관리 사용자 변경을 현재 로그인 세션의 권한 스냅샷에 반영한다."""
+ for session in _sessions.values():
+ if session.user_id != user.user_id:
+ continue
+ session.display_name = user.display_name
+ session.role = user.role
+ session.admin_access = has_admin_access(
+ user.email,
+ user.role,
+ user.admin_access,
+ )
+ session.super_admin = is_super_admin_email(user.email)
+ session.account_status = user.account_status
+ session.cohort_ids = list(user.cohort_ids)
+ session.consent_at = user.consent_at
+ session.profile_completed_at = user.profile_completed_at
+
+
async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
normalized_email = _normalize_email(data.email)
normalized_external_id = _normalize_external_id(data.external_id, normalized_email)
@@ -1183,11 +1225,11 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
reactivate=data.reactivate,
)
try:
- pool = get_pool()
- async with pool.acquire() as conn:
- if data.user_id is None and normalized_external_id != manual_external_id:
- row = await conn.fetchrow(
- """
+ pool = get_pool()
+ async with pool.acquire() as conn:
+ if data.user_id is None and normalized_external_id != manual_external_id:
+ row = await conn.fetchrow(
+ """
UPDATE app.app_user SET
external_id = $1,
display_name = COALESCE(NULLIF(display_name, ''), $3),
@@ -1234,19 +1276,25 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
created_at,
last_seen_at
""",
- normalized_external_id,
- normalized_email,
- (data.display_name.strip() if data.display_name else normalized_email),
- _cohort_value(data.cohort_ids),
- data.affiliation or DEFAULT_AFFILIATION,
- manual_external_id,
- )
- if row is not None:
- user = _managed_user_from_row(row)
- _memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True))
- return user
- row = await conn.fetchrow(
- """
+ normalized_external_id,
+ normalized_email,
+ (
+ data.display_name.strip()
+ if data.display_name
+ else normalized_email
+ ),
+ _cohort_value(data.cohort_ids),
+ data.affiliation or DEFAULT_AFFILIATION,
+ manual_external_id,
+ )
+ if row is not None:
+ user = _managed_user_from_row(row)
+ _memory_upsert_managed_user(
+ ManagedUserMemoryInput.from_user(user, reactivate=True)
+ )
+ return user
+ row = await conn.fetchrow(
+ """
INSERT INTO app.app_user (
external_id,
email,
@@ -1306,35 +1354,44 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
created_at,
last_seen_at
""",
- normalized_external_id,
- normalized_email,
- (data.display_name.strip() if data.display_name else normalized_email),
- _db_role(data.role),
- _cohort_value(data.cohort_ids),
- data.affiliation or DEFAULT_AFFILIATION,
- data.reactivate,
- desired_account_status,
- data.admin_access,
- )
- if row is None:
- _inactive_emails.add(normalized_email)
- raise InactiveUserError("user is inactive")
- user = _managed_user_from_row(row)
- _memory_upsert_managed_user(ManagedUserMemoryInput.from_user(user, reactivate=True))
- return user
+ normalized_external_id,
+ normalized_email,
+ (data.display_name.strip() if data.display_name else normalized_email),
+ _db_role(data.role),
+ _cohort_value(data.cohort_ids),
+ data.affiliation or DEFAULT_AFFILIATION,
+ data.reactivate,
+ desired_account_status,
+ data.admin_access,
+ )
+ if row is None:
+ _inactive_emails.add(normalized_email)
+ raise InactiveUserError("user is inactive")
+ user = _managed_user_from_row(row)
+ _memory_upsert_managed_user(
+ ManagedUserMemoryInput.from_user(user, reactivate=True)
+ )
+ return user
except InactiveUserError:
raise
except Exception:
require_runtime_fallback_allowed("managed user")
- current = _users.get(data.user_id or "") or _users.get(_email_index.get(normalized_email, ""))
+ current = _users.get(data.user_id or "") or _users.get(
+ _email_index.get(normalized_email, "")
+ )
fallback_uid = data.user_id or (
- current.user_id if current is not None else user_id_from_external_id(normalized_external_id)
+ current.user_id
+ if current is not None
+ else user_id_from_external_id(normalized_external_id)
)
fallback_account_status = desired_account_status
if current is not None:
if current.account_status == "suspended":
fallback_account_status = "suspended"
- elif current.account_status == "approved" and desired_account_status == "pending":
+ elif (
+ current.account_status == "approved"
+ and desired_account_status == "pending"
+ ):
fallback_account_status = "approved"
return _memory_upsert_managed_user(
ManagedUserMemoryInput(
@@ -1561,33 +1618,28 @@ async def update_managed_user(
patch.department.strip() if patch.department is not None else None,
patch.grade_level.strip() if patch.grade_level is not None else None,
patch.phone.strip() if patch.phone is not None else None,
- patch.contact_address.strip() if patch.contact_address is not None else None,
+ patch.contact_address.strip()
+ if patch.contact_address is not None
+ else None,
patch.nickname.strip() if patch.nickname is not None else None,
- patch.self_introduction.strip() if patch.self_introduction is not None else None,
+ patch.self_introduction.strip()
+ if patch.self_introduction is not None
+ else None,
patch.avatar_url.strip() if patch.avatar_url is not None else None,
patch.complete_onboarding,
- patch.terms_version.strip() if patch.terms_version is not None else None,
- patch.privacy_version.strip() if patch.privacy_version is not None else None,
+ patch.terms_version.strip()
+ if patch.terms_version is not None
+ else None,
+ patch.privacy_version.strip()
+ if patch.privacy_version is not None
+ else None,
patch.account_status,
patch.admin_access,
)
if row is not None:
next_user = _managed_user_from_row(row)
_memory_upsert_managed_user(ManagedUserMemoryInput.from_user(next_user))
- for session in _sessions.values():
- if session.user_id == user_id:
- session.display_name = next_user.display_name
- session.role = next_user.role
- session.admin_access = has_admin_access(
- next_user.email,
- next_user.role,
- next_user.admin_access,
- )
- session.super_admin = is_super_admin_email(next_user.email)
- session.account_status = next_user.account_status
- session.cohort_ids = list(next_user.cohort_ids)
- session.consent_at = next_user.consent_at
- session.profile_completed_at = next_user.profile_completed_at
+ _sync_managed_user_sessions(next_user)
return next_user
except Exception:
require_runtime_fallback_allowed("managed user update")
@@ -1601,32 +1653,58 @@ async def update_managed_user(
next_user = ManagedUser(
user_id=current.user_id,
email=current.email,
- display_name=patch.display_name.strip() if patch.display_name is not None else current.display_name,
+ display_name=patch.display_name.strip()
+ if patch.display_name is not None
+ else current.display_name,
role=patch.role if patch.role is not None else current.role,
- admin_access=patch.admin_access if patch.admin_access is not None else current.admin_access,
- account_status=patch.account_status if patch.account_status is not None else current.account_status,
- cohort_ids=list(patch.cohort_ids) if patch.cohort_ids is not None else current.cohort_ids,
- affiliation=patch.affiliation.strip() if patch.affiliation is not None else current.affiliation,
- legal_name=patch.legal_name.strip() if patch.legal_name is not None else current.legal_name,
- department=patch.department.strip() if patch.department is not None else current.department,
- grade_level=patch.grade_level.strip() if patch.grade_level is not None else current.grade_level,
+ admin_access=patch.admin_access
+ if patch.admin_access is not None
+ else current.admin_access,
+ account_status=patch.account_status
+ if patch.account_status is not None
+ else current.account_status,
+ cohort_ids=list(patch.cohort_ids)
+ if patch.cohort_ids is not None
+ else current.cohort_ids,
+ affiliation=patch.affiliation.strip()
+ if patch.affiliation is not None
+ else current.affiliation,
+ legal_name=patch.legal_name.strip()
+ if patch.legal_name is not None
+ else current.legal_name,
+ department=patch.department.strip()
+ if patch.department is not None
+ else current.department,
+ grade_level=patch.grade_level.strip()
+ if patch.grade_level is not None
+ else current.grade_level,
phone=patch.phone.strip() if patch.phone is not None else current.phone,
contact_address=(
patch.contact_address.strip()
if patch.contact_address is not None
else current.contact_address
),
- nickname=patch.nickname.strip() if patch.nickname is not None else current.nickname,
+ nickname=patch.nickname.strip()
+ if patch.nickname is not None
+ else current.nickname,
self_introduction=(
patch.self_introduction.strip()
if patch.self_introduction is not None
else current.self_introduction
),
- avatar_url=patch.avatar_url.strip() if patch.avatar_url is not None else current.avatar_url,
+ avatar_url=patch.avatar_url.strip()
+ if patch.avatar_url is not None
+ else current.avatar_url,
consent_at=current.consent_at,
- profile_completed_at=time.time() if patch.complete_onboarding else current.profile_completed_at,
- terms_agreed_at=time.time() if patch.complete_onboarding else current.terms_agreed_at,
- privacy_agreed_at=time.time() if patch.complete_onboarding else current.privacy_agreed_at,
+ profile_completed_at=time.time()
+ if patch.complete_onboarding
+ else current.profile_completed_at,
+ terms_agreed_at=time.time()
+ if patch.complete_onboarding
+ else current.terms_agreed_at,
+ privacy_agreed_at=time.time()
+ if patch.complete_onboarding
+ else current.privacy_agreed_at,
terms_version=(
patch.terms_version.strip()
if patch.complete_onboarding and patch.terms_version is not None
@@ -1641,21 +1719,7 @@ async def update_managed_user(
last_seen_at=time.time(),
)
_users[user_id] = next_user
-
- for session in _sessions.values():
- if session.user_id == user_id:
- session.display_name = next_user.display_name
- session.role = next_user.role
- session.admin_access = has_admin_access(
- next_user.email,
- next_user.role,
- next_user.admin_access,
- )
- session.super_admin = is_super_admin_email(next_user.email)
- session.account_status = next_user.account_status
- session.cohort_ids = list(next_user.cohort_ids)
- session.consent_at = next_user.consent_at
- session.profile_completed_at = next_user.profile_completed_at
+ _sync_managed_user_sessions(next_user)
return next_user
@@ -1865,7 +1929,9 @@ async def create_session(
email=normalized_email,
display_name=managed.display_name,
role=managed.role,
- admin_access=has_admin_access(managed.email, managed.role, managed.admin_access),
+ admin_access=has_admin_access(
+ managed.email, managed.role, managed.admin_access
+ ),
super_admin=is_super_admin_email(managed.email),
account_status=managed.account_status,
cohort_ids=list(managed.cohort_ids),
@@ -1976,7 +2042,9 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
managed.last_seen_at = time.time()
user.display_name = managed.display_name
user.role = managed.role
- user.admin_access = has_admin_access(managed.email, managed.role, managed.admin_access)
+ user.admin_access = has_admin_access(
+ managed.email, managed.role, managed.admin_access
+ )
user.super_admin = is_super_admin_email(managed.email)
user.account_status = managed.account_status
user.cohort_ids = list(managed.cohort_ids)
diff --git a/apps/api/app/config.py b/apps/api/app/config.py
index 554bb0b..ef3adf7 100644
--- a/apps/api/app/config.py
+++ b/apps/api/app/config.py
@@ -274,6 +274,23 @@ class Settings(BaseSettings):
validation_alias="ALLOW_SEED_PERSONA_FALLBACK",
)
+ # ── 회기 시간 제한 (2026-07-13 한신대 회의: 시간 기반 종료 전환) ──
+ # 임상 근거: 한 회기에 4단계가 모두 이뤄지지 않는 게 정상 — 종료는 단계 완수가 아니라
+ # 시간이 결정한다. 목표 달성 후에도 시간 내에는 계속 진행할 수 있다.
+ session_duration_minutes: int = Field(
+ default=60,
+ validation_alias="SESSION_DURATION_MINUTES",
+ )
+ session_warning_minutes: int = Field(
+ default=10,
+ validation_alias="SESSION_WARNING_MINUTES",
+ )
+ # 시간 만료 후에도 마무리 인사를 나눌 수 있는 유예. 유예까지 지나면 새 턴을 거부한다.
+ session_overtime_grace_minutes: int = Field(
+ default=10,
+ validation_alias="SESSION_OVERTIME_GRACE_MINUTES",
+ )
+
# ── SSE 스트리밍 ─────────────────────────────────────
sse_heartbeat_seconds: int = 30 # Cloudflare 100초 timeout 회피 (R2)
diff --git a/apps/api/app/engine_client.py b/apps/api/app/engine_client.py
index 8e8e503..1f6d59f 100644
--- a/apps/api/app/engine_client.py
+++ b/apps/api/app/engine_client.py
@@ -22,8 +22,8 @@ import httpx
from .config import settings
from .contracts.engine_gateway import (
- AIRole,
- EngineMessage,
+ AIRole as AIRole,
+ EngineMessage as EngineMessage,
EngineGatewaySseLineDecoder,
EngineGatewaySsePacket,
GenerateRequest,
diff --git a/apps/api/app/persona_repository.py b/apps/api/app/persona_repository.py
index c6ffa18..8fc0df8 100644
--- a/apps/api/app/persona_repository.py
+++ b/apps/api/app/persona_repository.py
@@ -117,7 +117,9 @@ def load_file_personas(persona_dir: Path = REPO_PERSONA_DIR) -> list[PersonaCard
def built_in_personas() -> list[PersonaCard]:
"""Return deterministic built-in catalog cards from code seeds plus repo JSON cards."""
- cards: dict[str, PersonaCard] = {card.code.upper(): card for card in SEED_PERSONAS.values()}
+ cards: dict[str, PersonaCard] = {
+ card.code.upper(): card for card in SEED_PERSONAS.values()
+ }
for card in load_file_personas():
cards.setdefault(card.code.upper(), card)
return [cards[code] for code in sorted(cards)]
@@ -222,7 +224,14 @@ def seed_fallback_persona(code: str) -> CatalogPersona | None:
normalized = code.strip().upper()
card = get_seed_persona(normalized)
if card is None:
- card = next((entry for entry in load_file_personas() if entry.code.upper() == normalized), None)
+ card = next(
+ (
+ entry
+ for entry in load_file_personas()
+ if entry.code.upper() == normalized
+ ),
+ None,
+ )
if card is None:
return None
return CatalogPersona(
@@ -448,6 +457,83 @@ async def get_persona_draft_record(
return persona_draft_record_from_row(row) if row is not None else None
+async def _insert_persona_card(
+ conn: Any,
+ *,
+ persona_id: str,
+ card: PersonaCard,
+ version: int,
+ next_status: str,
+ author_id: str,
+ returning_columns: str,
+) -> Any:
+ """Insert one immutable persona version from the canonical PersonaCard shape."""
+
+ return await conn.fetchrow(
+ f"""
+ INSERT INTO app.persona_card (
+ persona_id, code, version, status, display_name, difficulty,
+ theory_target, demographics, presenting, history, big5,
+ resistance, speech_style, affect_baseline, ccd,
+ dsm5_dimensional, source_provenance, is_synthetic, triggers,
+ created_by
+ )
+ VALUES (
+ $1::uuid, $2, $3, $4, $5, $6,
+ $7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
+ $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
+ $16::jsonb, $17, $18, $19::jsonb, $20::uuid
+ )
+ RETURNING {returning_columns}
+ """,
+ persona_id,
+ card.code,
+ version,
+ next_status,
+ card.display_name,
+ card.difficulty,
+ card.theory_target,
+ card.demographics,
+ card.presenting,
+ card.history,
+ card.big5,
+ card.resistance,
+ card.speech_style,
+ card.affect_baseline,
+ card.ccd,
+ card.dsm5_dimensional,
+ card.source_provenance,
+ card.is_synthetic,
+ card.triggers,
+ author_id,
+ )
+
+
+async def _record_persona_create_audit(
+ conn: Any,
+ *,
+ author_id: str,
+ action: str,
+ persona_id: str,
+ next_status: str,
+ card: PersonaCard,
+ version: int,
+) -> None:
+ await conn.execute(
+ """
+ INSERT INTO audit.audit_log (
+ actor_uid, action, target_kind, target_id, detail
+ )
+ VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
+ """,
+ author_id,
+ action,
+ "persona_card",
+ persona_id,
+ {"next_status": next_status, "code": card.code, "version": version},
+ )
+
+
async def create_persona_draft(
*,
card: PersonaCard,
@@ -470,60 +556,23 @@ async def create_persona_draft(
""",
card.code,
)
- row = await conn.fetchrow(
- f"""
- INSERT INTO app.persona_card (
- persona_id, code, version, status, display_name, difficulty,
- theory_target, demographics, presenting, history, big5,
- resistance, speech_style, affect_baseline, ccd,
- dsm5_dimensional, source_provenance, is_synthetic, triggers,
- created_by
- )
- VALUES (
- $1::uuid, $2, $3, $4, $5, $6,
- $7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
- $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
- $16::jsonb, $17, $18, $19::jsonb, $20::uuid
- )
- RETURNING {_REVIEW_COLUMNS}
- """,
- persona_id,
- card.code,
- int(version or 1),
- next_status,
- card.display_name,
- card.difficulty,
- card.theory_target,
- card.demographics,
- card.presenting,
- card.history,
- card.big5,
- card.resistance,
- card.speech_style,
- card.affect_baseline,
- card.ccd,
- card.dsm5_dimensional,
- card.source_provenance,
- card.is_synthetic,
- card.triggers,
- author_id,
+ row = await _insert_persona_card(
+ conn,
+ persona_id=persona_id,
+ card=card,
+ version=int(version or 1),
+ next_status=next_status,
+ author_id=author_id,
+ returning_columns=_REVIEW_COLUMNS,
)
- await conn.execute(
- """
- INSERT INTO audit.audit_log (
- actor_uid, action, target_kind, target_id, detail
- )
- VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
- """,
- author_id,
- "persona_draft_create",
- "persona_card",
- persona_id,
- {
- "next_status": next_status,
- "code": card.code,
- "version": int(row["version"]),
- },
+ await _record_persona_create_audit(
+ conn,
+ author_id=author_id,
+ action="persona_draft_create",
+ persona_id=persona_id,
+ next_status=next_status,
+ card=card,
+ version=int(row["version"]),
)
return persona_review_item_from_row(row)
@@ -577,60 +626,23 @@ async def create_persona_revision_from_existing(
""",
card.code,
)
- row = await conn.fetchrow(
- f"""
- INSERT INTO app.persona_card (
- persona_id, code, version, status, display_name, difficulty,
- theory_target, demographics, presenting, history, big5,
- resistance, speech_style, affect_baseline, ccd,
- dsm5_dimensional, source_provenance, is_synthetic, triggers,
- created_by
- )
- VALUES (
- $1::uuid, $2, $3, $4, $5, $6,
- $7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
- $12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
- $16::jsonb, $17, $18, $19::jsonb, $20::uuid
- )
- RETURNING {_CARD_COLUMNS}, created_at, approved_at
- """,
- persona_id,
- card.code,
- int(version or 1),
- next_status,
- card.display_name,
- card.difficulty,
- card.theory_target,
- card.demographics,
- card.presenting,
- card.history,
- card.big5,
- card.resistance,
- card.speech_style,
- card.affect_baseline,
- card.ccd,
- card.dsm5_dimensional,
- card.source_provenance,
- card.is_synthetic,
- card.triggers,
- author_id,
+ row = await _insert_persona_card(
+ conn,
+ persona_id=persona_id,
+ card=card,
+ version=int(version or 1),
+ next_status=next_status,
+ author_id=author_id,
+ returning_columns=f"{_CARD_COLUMNS}, created_at, approved_at",
)
- await conn.execute(
- """
- INSERT INTO audit.audit_log (
- actor_uid, action, target_kind, target_id, detail
- )
- VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
- """,
- author_id,
- "persona_revision_create",
- "persona_card",
- persona_id,
- {
- "next_status": next_status,
- "code": card.code,
- "version": int(row["version"]),
- },
+ await _record_persona_create_audit(
+ conn,
+ author_id=author_id,
+ action="persona_revision_create",
+ persona_id=persona_id,
+ next_status=next_status,
+ card=card,
+ version=int(row["version"]),
)
return persona_draft_record_from_row(row)
diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py
index 9f4780c..b416018 100644
--- a/apps/api/app/routes/admin.py
+++ b/apps/api/app/routes/admin.py
@@ -51,6 +51,70 @@ TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
NotificationDeliveryStatus = Literal["queued", "sending", "sent", "failed", "skipped"]
+METERED_CLIENT_TURN_FILTER_SQL = """
+ speaker = 'client'
+ AND (
+ llm_provider IS NOT NULL OR model IS NOT NULL
+ OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
+ OR cost_usd IS NOT NULL
+ )
+"""
+
+USAGE_AGGREGATE_COLUMNS_SQL = """
+ COUNT(*) AS turns,
+ COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
+ COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
+ COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
+"""
+
+SUPPORT_TICKET_DETAIL_FROM_SQL = """
+ SELECT
+ t.id,
+ t.reporter_id,
+ t.reporter_email,
+ t.reporter_name,
+ t.reporter_role,
+ t.category,
+ t.priority,
+ t.status,
+ t.subject,
+ t.body,
+ t.source_path,
+ t.fingerprint,
+ t.parent_ticket_id,
+ t.assigned_group,
+ t.resolution_note,
+ t.created_at,
+ t.updated_at,
+ t.resolved_at,
+ COALESCE(dup.duplicate_count, 0) AS duplicate_count,
+ dup.parent_candidate_id AS duplicate_parent_candidate_id,
+ COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
+ COALESCE(ev.event_count, 0) AS event_count,
+ ev.last_event_at
+ FROM app.support_ticket AS t
+ LEFT JOIN LATERAL (
+ SELECT
+ count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
+ (array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
+ FROM app.support_ticket
+ WHERE fingerprint <> ''
+ AND fingerprint = t.fingerprint
+ ) AS dup ON TRUE
+ LEFT JOIN LATERAL (
+ SELECT count(*)::int AS child_ticket_count
+ FROM app.support_ticket
+ WHERE parent_ticket_id = t.id
+ ) AS child ON TRUE
+ LEFT JOIN LATERAL (
+ SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
+ FROM audit.audit_log
+ WHERE action = 'support_ticket_update'
+ AND target_kind = 'support_ticket'
+ AND target_id = t.id::text
+ ) AS ev ON TRUE
+"""
+
class AdminServiceHealth(BaseModel):
key: str
@@ -450,17 +514,10 @@ async def _runtime_health_metrics(*, db_ok: bool) -> RuntimeHealthMetrics:
async def _usage_from_database(window_days: int) -> AdminUsageResponse:
async with acquire(role="admin") as conn:
total_row = await conn.fetchrow(
- """
+ f"""
SELECT
COUNT(*) FILTER (WHERE speaker = 'client') AS total_turns,
- COUNT(*) FILTER (
- WHERE speaker = 'client'
- AND (
- llm_provider IS NOT NULL OR model IS NOT NULL
- OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
- OR cost_usd IS NOT NULL
- )
- ) AS metered_turns,
+ COUNT(*) FILTER (WHERE {METERED_CLIENT_TURN_FILTER_SQL}) AS metered_turns,
COALESCE(SUM(tokens_in) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_in,
COALESCE(SUM(tokens_out) FILTER (WHERE speaker = 'client'), 0)::bigint AS tokens_out,
COALESCE(SUM(cost_usd) FILTER (WHERE speaker = 'client'), 0)::numeric AS cost_usd
@@ -470,22 +527,14 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
window_days,
)
rows = await conn.fetch(
- """
+ f"""
SELECT
COALESCE(llm_provider, 'unknown') AS provider,
COALESCE(model, 'unknown') AS model,
- COUNT(*) AS turns,
- COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
- COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
- COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
+ {USAGE_AGGREGATE_COLUMNS_SQL}
FROM app.turns
WHERE created_at >= now() - ($1::int * interval '1 day')
- AND speaker = 'client'
- AND (
- llm_provider IS NOT NULL OR model IS NOT NULL
- OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
- OR cost_usd IS NOT NULL
- )
+ AND {METERED_CLIENT_TURN_FILTER_SQL}
GROUP BY 1, 2
ORDER BY
cost_usd DESC,
@@ -496,21 +545,13 @@ async def _usage_from_database(window_days: int) -> AdminUsageResponse:
window_days,
)
daily_rows = await conn.fetch(
- """
+ f"""
SELECT
to_char(date_trunc('day', created_at), 'YYYY-MM-DD') AS day,
- COUNT(*) AS turns,
- COALESCE(SUM(tokens_in), 0)::bigint AS tokens_in,
- COALESCE(SUM(tokens_out), 0)::bigint AS tokens_out,
- COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd
+ {USAGE_AGGREGATE_COLUMNS_SQL}
FROM app.turns
WHERE created_at >= now() - ($1::int * interval '1 day')
- AND speaker = 'client'
- AND (
- llm_provider IS NOT NULL OR model IS NOT NULL
- OR tokens_in IS NOT NULL OR tokens_out IS NOT NULL
- OR cost_usd IS NOT NULL
- )
+ AND {METERED_CLIENT_TURN_FILTER_SQL}
GROUP BY 1
ORDER BY 1
""",
@@ -761,52 +802,8 @@ async def _tickets_from_database(
search_filter = search.strip().lower()
async with acquire(role="admin") as conn:
rows = await conn.fetch(
- """
- SELECT
- t.id,
- t.reporter_id,
- t.reporter_email,
- t.reporter_name,
- t.reporter_role,
- t.category,
- t.priority,
- t.status,
- t.subject,
- t.body,
- t.source_path,
- t.fingerprint,
- t.parent_ticket_id,
- t.assigned_group,
- t.resolution_note,
- t.created_at,
- t.updated_at,
- t.resolved_at,
- COALESCE(dup.duplicate_count, 0) AS duplicate_count,
- dup.parent_candidate_id AS duplicate_parent_candidate_id,
- COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
- COALESCE(ev.event_count, 0) AS event_count,
- ev.last_event_at
- FROM app.support_ticket AS t
- LEFT JOIN LATERAL (
- SELECT
- count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
- (array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
- FROM app.support_ticket
- WHERE fingerprint <> ''
- AND fingerprint = t.fingerprint
- ) AS dup ON TRUE
- LEFT JOIN LATERAL (
- SELECT count(*)::int AS child_ticket_count
- FROM app.support_ticket
- WHERE parent_ticket_id = t.id
- ) AS child ON TRUE
- LEFT JOIN LATERAL (
- SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
- FROM audit.audit_log
- WHERE action = 'support_ticket_update'
- AND target_kind = 'support_ticket'
- AND target_id = t.id::text
- ) AS ev ON TRUE
+ SUPPORT_TICKET_DETAIL_FROM_SQL
+ + """
WHERE ($1::text IS NULL OR status = $1)
AND ($2::text IS NULL OR category = $2)
AND ($3::text IS NULL OR priority = $3)
@@ -1736,54 +1733,7 @@ async def patch_ticket(
detail=detail,
)
row = await conn.fetchrow(
- """
- SELECT
- t.id,
- t.reporter_id,
- t.reporter_email,
- t.reporter_name,
- t.reporter_role,
- t.category,
- t.priority,
- t.status,
- t.subject,
- t.body,
- t.source_path,
- t.fingerprint,
- t.parent_ticket_id,
- t.assigned_group,
- t.resolution_note,
- t.created_at,
- t.updated_at,
- t.resolved_at,
- COALESCE(dup.duplicate_count, 0) AS duplicate_count,
- dup.parent_candidate_id AS duplicate_parent_candidate_id,
- COALESCE(child.child_ticket_count, 0) AS child_ticket_count,
- COALESCE(ev.event_count, 0) AS event_count,
- ev.last_event_at
- FROM app.support_ticket AS t
- LEFT JOIN LATERAL (
- SELECT
- count(*) FILTER (WHERE id <> t.id)::int AS duplicate_count,
- (array_agg(id::text ORDER BY created_at ASC, id ASC))[1] AS parent_candidate_id
- FROM app.support_ticket
- WHERE fingerprint <> ''
- AND fingerprint = t.fingerprint
- ) AS dup ON TRUE
- LEFT JOIN LATERAL (
- SELECT count(*)::int AS child_ticket_count
- FROM app.support_ticket
- WHERE parent_ticket_id = t.id
- ) AS child ON TRUE
- LEFT JOIN LATERAL (
- SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
- FROM audit.audit_log
- WHERE action = 'support_ticket_update'
- AND target_kind = 'support_ticket'
- AND target_id = t.id::text
- ) AS ev ON TRUE
- WHERE t.id = $1::uuid
- """,
+ SUPPORT_TICKET_DETAIL_FROM_SQL + " WHERE t.id = $1::uuid",
ticket_id,
)
except Exception as exc:
diff --git a/apps/api/app/routes/auth.py b/apps/api/app/routes/auth.py
index 9228893..296f3e6 100644
--- a/apps/api/app/routes/auth.py
+++ b/apps/api/app/routes/auth.py
@@ -967,7 +967,7 @@ async def callback(
cohort_ids=cohort_ids,
external_id=external_id,
)
- except InactiveUserError as exc:
+ except InactiveUserError:
_log_oauth_callback_failure(
request,
"inactive_user",
diff --git a/apps/api/app/routes/kb.py b/apps/api/app/routes/kb.py
index c30ba67..7fe91d2 100644
--- a/apps/api/app/routes/kb.py
+++ b/apps/api/app/routes/kb.py
@@ -33,7 +33,7 @@ RoleLiteral = Literal["client", "counselor", "evaluator"]
class KBSearchRequest(BaseModel):
query: str = Field(..., min_length=1) # PII 마스킹된 질의(마스킹은 가드레일 책임)
- role: RoleLiteral = "evaluator" # 검색 주체(정보비대칭 정책 선택)
+ role: RoleLiteral = "evaluator" # 검색 주체(정보비대칭 정책 선택)
k: int = Field(default=5, ge=1, le=50)
rerank: bool = True
# 정책 화이트리스트를 *좁히는* 추가 필터만 허용(넓히지 못함 — 정보비대칭 보존)
@@ -51,9 +51,9 @@ class ChunkOut(BaseModel):
kb_kind: str
heading_path: Optional[str] = None
context_prefix: Optional[str] = None
- body: Optional[str] = None # expose_body=True(상담사/평가) 정책에서만
- behavior_cue: Optional[str] = None # 내담자 정책: 본문 비노출, 행동단서만(M6)
- label_id: Optional[int] = None # 평가 정책에서만
+ body: Optional[str] = None # expose_body=True(상담사/평가) 정책에서만
+ behavior_cue: Optional[str] = None # 내담자 정책: 본문 비노출, 행동단서만(M6)
+ label_id: Optional[int] = None # 평가 정책에서만
meta: dict[str, Any] = Field(default_factory=dict)
source_id: Optional[str] = None
@@ -62,9 +62,9 @@ class KBSearchResponse(BaseModel):
chunks: list[ChunkOut]
policy: str
top1_score: float
- crag_pass: bool # top1 >= 임계(F-06: 미달 시 관찰 프레이밍)
+ crag_pass: bool # top1 >= 임계(F-06: 미달 시 관찰 프레이밍)
latency_ms: int
- degraded: bool = False # reranker/embed 폴백 투명성
+ degraded: bool = False # reranker/embed 폴백 투명성
class MemoryRecallRequest(BaseModel):
@@ -79,11 +79,11 @@ class IndexChunkIn(BaseModel):
seq: int
chunk_text: str = Field(..., min_length=1)
heading_path: Optional[str] = None
- context_prefix: Optional[str] = None # Contextual Retrieval 프리픽스(색인 대상)
+ context_prefix: Optional[str] = None # Contextual Retrieval 프리픽스(색인 대상)
kb_kind: Optional[str] = None
- visible_to: Optional[list[str]] = None # 미지정 시 {client,counselor,evaluator}
+ visible_to: Optional[list[str]] = None # 미지정 시 {client,counselor,evaluator}
sensitivity: Optional[int] = Field(default=None, ge=0, le=3)
- label_id: Optional[int] = None # taxonomy 정답 라벨 FK
+ label_id: Optional[int] = None # taxonomy 정답 라벨 FK
meta: Optional[dict[str, Any]] = None
token_count: Optional[int] = None
@@ -99,8 +99,8 @@ class IndexRequestIn(BaseModel):
class IndexResponse(BaseModel):
doc_id: Optional[int]
chunks_indexed: int
- skipped_unchanged: bool # content_hash 동일 → 증분 스킵
- embedded: bool # 임베딩 적재 여부(모델 미가용 시 False)
+ skipped_unchanged: bool # content_hash 동일 → 증분 스킵
+ embedded: bool # 임베딩 적재 여부(모델 미가용 시 False)
degraded: bool = False
@@ -211,16 +211,11 @@ async def search(body: KBSearchRequest) -> KBSearchResponse:
)
except RuntimeError as e:
# DB 풀 미초기화(lifespan 밖) — 시연/테스트 degraded
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
+ )
- return KBSearchResponse(
- chunks=[_to_chunk_out(c) for c in result.chunks],
- policy=result.policy_name,
- top1_score=round(result.top1_score, 6),
- crag_pass=result.top1_score >= rag.CRAG_TOP1_THRESHOLD,
- latency_ms=result.latency_ms,
- degraded=result.degraded,
- )
+ return _search_response(result)
# ════════════════════════════════════════════════════════════════════════════
@@ -254,18 +249,15 @@ async def eval_grounding(body: KBSearchRequest) -> KBSearchResponse:
except Exception:
pass
except rag.NotConfigured as e:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}")
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}"
+ )
except RuntimeError as e:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
+ )
- return KBSearchResponse(
- chunks=[_to_chunk_out(c) for c in result.chunks],
- policy=result.policy_name,
- top1_score=round(result.top1_score, 6),
- crag_pass=result.top1_score >= rag.CRAG_TOP1_THRESHOLD,
- latency_ms=result.latency_ms,
- degraded=result.degraded,
- )
+ return _search_response(result)
# ════════════════════════════════════════════════════════════════════════════
@@ -287,9 +279,19 @@ async def persona_memory(body: MemoryRecallRequest) -> KBSearchResponse:
k=body.k,
)
except rag.NotConfigured as e:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}")
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}"
+ )
except RuntimeError as e:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
+ )
+
+ return _search_response(result)
+
+
+def _search_response(result: rag.SearchResult) -> KBSearchResponse:
+ """Project every RAG policy result through the same browser-facing contract."""
return KBSearchResponse(
chunks=[_to_chunk_out(c) for c in result.chunks],
@@ -304,7 +306,9 @@ async def persona_memory(body: MemoryRecallRequest) -> KBSearchResponse:
# ════════════════════════════════════════════════════════════════════════════
# 인덱싱 트리거 — 관리자 전용(content_hash 증분, 오프라인 배치)
# ════════════════════════════════════════════════════════════════════════════
-@router.post("/index", response_model=IndexResponse, status_code=status.HTTP_202_ACCEPTED)
+@router.post(
+ "/index", response_model=IndexResponse, status_code=status.HTTP_202_ACCEPTED
+)
async def index_document(
body: IndexRequestIn,
principal: Annotated[Principal, Depends(require_role(Role.ADMIN))],
@@ -329,9 +333,13 @@ async def index_document(
except rag.IndexPolicyViolation as e:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e
except rag.NotConfigured as e:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}")
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {e}"
+ )
except RuntimeError as e:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}")
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
+ )
return IndexResponse(
doc_id=result.doc_id,
@@ -356,7 +364,9 @@ async def sync_live_coach_source_packs(
evaluator RAG 검색에도 올린다. source row를 먼저 upsert한 뒤 content_hash 기반 증분 색인을
수행한다. 임베딩 모델 미가용 시 BM25-only degraded 색인으로 이어진다.
"""
- source_rows, index_payloads = source_pack_sync.build_repo_source_pack_manifest(refresh=True)
+ source_rows, index_payloads = source_pack_sync.build_repo_source_pack_manifest(
+ refresh=True
+ )
if not source_rows or not index_payloads:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
@@ -365,11 +375,17 @@ async def sync_live_coach_source_packs(
try:
async with acquire() as conn:
- result = await source_pack_sync.sync_repo_source_packs(conn, apply=True, refresh=True)
+ result = await source_pack_sync.sync_repo_source_packs(
+ conn, apply=True, refresh=True
+ )
except rag.NotConfigured as exc:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}") from exc
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}"
+ ) from exc
except RuntimeError as exc:
- raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
+ raise HTTPException(
+ status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}"
+ ) from exc
return LiveCoachSourcePackSyncResponse(
sources_upserted=result.sources_upserted,
diff --git a/apps/api/app/routes/personas.py b/apps/api/app/routes/personas.py
index ac884f3..2315041 100644
--- a/apps/api/app/routes/personas.py
+++ b/apps/api/app/routes/personas.py
@@ -8,7 +8,7 @@ import re
import uuid
from typing import Annotated, Any, Literal
-from fastapi import APIRouter, Depends, HTTPException, Response, status
+from fastapi import APIRouter, Depends, File, Form, HTTPException, Response, UploadFile, status
from ..db import acquire
from ..deps import CurrentPrincipal, Principal, Role, require_role
@@ -52,6 +52,7 @@ from ..persona_read_model import (
from ..engine_client import EngineError, EngineMessage, GenerateRequest, engine_client
from ..services import rag
from ..services.guardrail import mask_pii
+from ..services.tabular_ingest import TabularIngestError, extract_tabular_text
router = APIRouter(prefix="/personas", tags=["personas"])
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
@@ -555,6 +556,42 @@ async def create_persona_source_route(
return await _register_persona_source_document(request, principal)
+@router.post(
+ "/sources/upload",
+ response_model=PersonaSourceDocumentResponse,
+ status_code=status.HTTP_201_CREATED,
+)
+async def upload_persona_source_route(
+ principal: TeacherOrAdmin,
+ file: UploadFile = File(...),
+ source_kind: PersonaSourceKind = Form("mixed_notes"),
+ title: str | None = Form(None),
+ source_note: str = Form(""),
+) -> PersonaSourceDocumentResponse:
+ """자유 양식 엑셀/CSV 업로드 → 텍스트 변환 → 기존 source 등록 경로 재사용 (P4).
+
+ 업로드 원본 바이트는 이 핸들러 메모리에서만 파싱하고 저장하지 않는다(원본 파기).
+ 파생 텍스트만 기존 마스킹·hash-only 증거·sanitized chunk 경로로 등록된다.
+ """
+ _ensure_teacher_or_admin(principal)
+ data = await file.read()
+ try:
+ text = extract_tabular_text(filename=file.filename or "", data=data)
+ except TabularIngestError as exc:
+ raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(exc)) from exc
+ finally:
+ # 원본 바이트 참조를 즉시 놓는다 — 파생 텍스트 외에는 남기지 않는다.
+ del data
+ request = PersonaSourceDocumentRequest(
+ filename=(file.filename or "uploaded-table.xlsx")[:240],
+ source_kind=source_kind,
+ text=text,
+ title=(title or None),
+ source_note=source_note[:800],
+ )
+ return await _register_persona_source_document(request, principal)
+
+
@router.post("/drafts/generate", response_model=PersonaDraftGenerateResponse)
async def generate_persona_draft_route(
request: PersonaDraftGenerateRequest,
diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py
index d447d5f..461db5e 100644
--- a/apps/api/app/routes/sessions.py
+++ b/apps/api/app/routes/sessions.py
@@ -12,11 +12,12 @@ import asyncio
import json
import logging
import secrets
+import time
from datetime import datetime
from typing import Literal, Optional
from fastapi import APIRouter, HTTPException, Request, status
-from pydantic import BaseModel, Field
+from pydantic import BaseModel, Field, field_validator
from sse_starlette.sse import EventSourceResponse
from .. import db, session_persistence, turn_runtime
@@ -46,15 +47,17 @@ from ..session_read_model import (
MISSING_SESSION_EVALUATION_GRACE_SECONDS,
ReviewCaseWorksheet,
ReviewCaseWorksheetSaveRequest,
- ReviewWorksheetItem,
- ReviewWorksheetSection,
+ ReviewWorksheetItem as ReviewWorksheetItem,
+ ReviewWorksheetSection as ReviewWorksheetSection,
SessionArchiveResponse,
SessionDetailResponse,
SessionReviewReadInput,
SessionReviewResponse,
+ SessionProgress,
SessionShareDeleteResponse,
SessionShareResponse,
StageLabel,
+ build_session_progress,
build_session_review,
dashboard_achievements as _dashboard_achievements,
dashboard_feedback as _dashboard_feedback,
@@ -68,7 +71,7 @@ from ..session_read_model import (
session_share_payload as _session_share_payload,
stage_label as _stage_label,
)
-from ..store import InProcSession, TurnRecord, store
+from ..store import InProcSession, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
logger = logging.getLogger(__name__)
@@ -82,6 +85,19 @@ EndStateValue = str | int | float | bool | None | dict[str, float]
class SessionStartRequest(BaseModel):
persona_code: str = Field(..., examples=["P1"])
theory_mode: TheoryMode = "humanistic"
+ # 이번 회기 목표 단계(2026-07-13 회의 P1). 회의 권장은 2개 수준이지만
+ # 소유자 지시(2026-07-15)로 1~4개까지 자유 선택을 허용한다.
+ # 빈 리스트는 구계약 클라이언트 호환용 — 준비 페이지는 항상 1개 이상을 보낸다.
+ goal_stages: list[StageLabel] = Field(default_factory=list, max_length=4)
+
+ @field_validator("goal_stages")
+ @classmethod
+ def _dedupe_goal_stages(cls, value: list[StageLabel]) -> list[StageLabel]:
+ seen: list[StageLabel] = []
+ for stage in value:
+ if stage not in seen:
+ seen.append(stage)
+ return seen[:4]
class SessionStartResponse(BaseModel):
@@ -92,6 +108,11 @@ class SessionStartResponse(BaseModel):
effective_openness: float
recall_summary: Optional[str] = None
degraded: bool = False
+ started_at: str = ""
+ goal_stages: list[StageLabel] = Field(default_factory=list)
+ # 시간 기반 회기 종료 계약(회의 P1): 프론트 타이머·10분 전 알람의 기준값.
+ duration_limit_seconds: int = 0
+ warning_before_end_seconds: int = 0
class TurnRequest(BaseModel):
@@ -132,6 +153,8 @@ class TurnResponse(BaseModel):
crisis_resource: Optional[CrisisResourceResponse] = None
conversation_stopped: bool = False
output_error: Optional[str] = None
+ # P2 단계 누적 게이지·상세 수치 — 턴마다 갱신된 파생값.
+ progress: Optional[SessionProgress] = None
class SessionEndResponse(BaseModel):
@@ -146,6 +169,20 @@ _RECALL_CACHE: dict[str, memory.RecallContext] = {}
_KB_CUES_CACHE: dict[str, list[str]] = {}
_RAG_WARM_SEMAPHORE = asyncio.Semaphore(1)
+
+def cached_kb_cues(session_id: str) -> list[str]:
+ """Return a defensive copy of the session-scoped, process-lifetime KB cues."""
+
+ return list(_KB_CUES_CACHE.get(session_id) or [])
+
+
+def invalidate_session_context_cache(session_id: str) -> None:
+ """Invalidate all derived turn context when a session reaches its terminal state."""
+
+ _RECALL_CACHE.pop(session_id, None)
+ _KB_CUES_CACHE.pop(session_id, None)
+
+
# ────────────────────────────────────────────────────────────────────────────
# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful
# degradation: 상담 루프를 절대 막지 않는다(라이브 루프 비차단이 계약). routes/kb.py가
@@ -213,7 +250,13 @@ async def _retrieve_live_coach_grounding(
conn,
query=query,
k=4,
- kinds=("theory", "technique", "supervisor_pattern", "microskill", "taxonomy"),
+ kinds=(
+ "theory",
+ "technique",
+ "supervisor_pattern",
+ "microskill",
+ "taxonomy",
+ ),
)
try:
await rag.log_retrieval(
@@ -233,9 +276,16 @@ async def _retrieve_live_coach_grounding(
if not body:
continue
meta = chunk.meta if isinstance(chunk.meta, dict) else {}
- title = str(meta.get("source_title") or meta.get("title") or chunk.source_id or "Vignette KB").strip()
+ title = str(
+ meta.get("source_title")
+ or meta.get("title")
+ or chunk.source_id
+ or "Vignette KB"
+ ).strip()
source_type = str(meta.get("source_type") or "").strip()
- source_version = str(meta.get("source_version") or meta.get("version") or "").strip()
+ source_version = str(
+ meta.get("source_version") or meta.get("version") or ""
+ ).strip()
citation = str(meta.get("citation") or "").strip()
out.append(
live_coach.LiveCoachGrounding(
@@ -252,7 +302,9 @@ async def _retrieve_live_coach_grounding(
return out
-def _latest_turn_evaluation(sess: InProcSession, turn_seq: int | None) -> Optional[dict]:
+def _latest_turn_evaluation(
+ sess: InProcSession, turn_seq: int | None
+) -> Optional[dict]:
"""방금 상담자 발화에 붙은 fast-loop 평가를 찾는다."""
for turn in reversed(sess.turns):
if turn.speaker != "counselor":
@@ -322,7 +374,8 @@ async def _load_case_memory(case_id: str) -> dict:
"end_state": dict(summary_row["end_state"] or {}),
}
return {
- "case_digest": (case_row["case_digest"] if case_row is not None else None) or None,
+ "case_digest": (case_row["case_digest"] if case_row is not None else None)
+ or None,
"prev_summary": prev_summary,
"pinned_facts": [row["value"] for row in fact_rows if row["value"]],
}
@@ -361,7 +414,10 @@ async def _episodic_recall_snippets(case_id: str, query: str) -> list[str]:
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
result = await rag.retrieve_persona_memory(
- conn, case_id=case_id, query=query, k=_RAG_RECALL_K,
+ conn,
+ case_id=case_id,
+ query=query,
+ k=_RAG_RECALL_K,
)
return await _hydrate_episodic_text(conn, result)
except Exception:
@@ -412,14 +468,39 @@ async def ensure_recall_context(sess: InProcSession) -> memory.RecallContext:
return recall
+def session_time_over(sess: InProcSession) -> bool:
+ """시간 기반 회기 종료(회의 P1): 제한 + 마무리 유예까지 지난 세션인지 판정.
+
+ 제한 시간(기본 60분) 도달 자체는 프론트가 정리 유도·자동 종료로 처리하고,
+ 서버는 유예(기본 +10분)까지 지난 뒤의 새 턴만 거부한다(마무리 인사 허용).
+ """
+ if settings.session_duration_minutes <= 0:
+ return False
+ limit_seconds = (
+ settings.session_duration_minutes + settings.session_overtime_grace_minutes
+ ) * 60
+ return (time.time() - sess.created_at) > limit_seconds
+
+
+def _ensure_turn_time_allowed(sess: InProcSession) -> None:
+ if session_time_over(sess):
+ raise HTTPException(
+ status.HTTP_409_CONFLICT,
+ detail="session_time_over",
+ )
+
+
async def _prepare_turn_context(
*,
session_id: str,
sess: InProcSession,
learner_text: str,
) -> orchestrator.TurnContext:
+ _ensure_turn_time_allowed(sess)
recall = await ensure_recall_context(sess)
- kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
+ kb_cues = (
+ _KB_CUES_CACHE.get(session_id) or []
+ ) # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
session_id=session_id,
case_id=sess.case_id,
@@ -446,7 +527,9 @@ async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
"""
async with _RAG_WARM_SEMAPHORE:
try:
- _RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
+ _RECALL_CACHE[session_id] = await _build_start_recall(
+ case_id=case_id, card=card
+ )
except Exception:
pass
try:
@@ -460,7 +543,9 @@ def _ensure_learner(principal: Principal) -> Principal:
return principal
if principal.can_access_role(Role.LEARNER):
return principal.with_role(Role.LEARNER)
- raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions")
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN, detail="only learners can use sessions"
+ )
async def _ensure_practice_consent(principal: Principal) -> None:
@@ -495,7 +580,9 @@ async def _load_session_or_404(
if err == turn_runtime.SessionAccessError.NOT_FOUND:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found")
if err == turn_runtime.SessionAccessError.FORBIDDEN:
- raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session does not belong to user")
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN, detail="session does not belong to user"
+ )
if err == turn_runtime.SessionAccessError.ENDED:
raise HTTPException(status.HTTP_409_CONFLICT, detail="session already ended")
assert sess is not None
@@ -562,7 +649,9 @@ async def _load_review_session_or_404(
supervisor = _review_supervisor_principal(principal)
if supervisor is None:
- raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied")
+ raise HTTPException(
+ status.HTTP_403_FORBIDDEN, detail="session review access denied"
+ )
return (
await _load_supervisor_review_session_or_404(
session_id,
@@ -587,7 +676,9 @@ async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -
def _should_schedule_session_digest_worker(carry: memory.CarryOver) -> bool:
- return bool(settings.session_digest_worker_enabled and carry.compression_job is not None)
+ return bool(
+ settings.session_digest_worker_enabled and carry.compression_job is not None
+ )
async def _run_session_digest_worker_for_session(session_id: str) -> None:
@@ -600,7 +691,9 @@ async def _run_session_digest_worker_for_session(session_id: str) -> None:
try:
db.get_pool()
async with db.acquire(role="admin") as conn:
- loaded = await session_digest_worker.load_session_digest_job(conn, session_id)
+ loaded = await session_digest_worker.load_session_digest_job(
+ conn, session_id
+ )
if loaded is None:
return
model = settings.session_digest_worker_model.strip() or None
@@ -620,7 +713,9 @@ async def _run_session_digest_worker_for_session(session_id: str) -> None:
learner_id=loaded.learner_id,
)
except Exception:
- logger.warning("session digest worker failed for session_id=%s", session_id, exc_info=True)
+ logger.warning(
+ "session digest worker failed for session_id=%s", session_id, exc_info=True
+ )
return
@@ -653,7 +748,10 @@ def _public_share_url(request: Request, token: str) -> str:
base = str(request.base_url).rstrip("/")
return f"{base}/share/session/{token}"
-async def _evaluate_stream_turn(ctx: orchestrator.TurnContext, final_reply: str) -> Optional[dict]:
+
+async def _evaluate_stream_turn(
+ ctx: orchestrator.TurnContext, final_reply: str
+) -> Optional[dict]:
"""stream 경로 완료 후 fast-loop 평가를 계산한다. 실패는 턴 저장을 막지 않는다."""
if not final_reply:
return None
@@ -689,7 +787,9 @@ def _stream_result_from_done(
state_after=ctx.state_after,
evaluation=evaluation,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
- crisis_resource=data.get("crisis_resource") if isinstance(data.get("crisis_resource"), dict) else None,
+ crisis_resource=data.get("crisis_resource")
+ if isinstance(data.get("crisis_resource"), dict)
+ else None,
conversation_stopped=bool(data.get("conversation_stopped")),
llm_provider=str(data.get("llm_provider") or "") or None,
model=str(data.get("model") or "") or None,
@@ -780,9 +880,13 @@ def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str)
try:
task.result()
except asyncio.CancelledError:
- logger.warning("session evaluation background task cancelled: session_id=%s", session_id)
+ logger.warning(
+ "session evaluation background task cancelled: session_id=%s", session_id
+ )
except Exception:
- logger.exception("session evaluation background task crashed: session_id=%s", session_id)
+ logger.exception(
+ "session evaluation background task crashed: session_id=%s", session_id
+ )
def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None:
@@ -800,20 +904,26 @@ def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | No
name=f"session-evaluation:{sess.session_id}",
)
task.add_done_callback(
- lambda done, session_id=sess.session_id: _observe_session_evaluation_task(done, session_id)
+ lambda done, session_id=sess.session_id: _observe_session_evaluation_task(
+ done, session_id
+ )
)
return task
async def recover_missing_session_evaluations(*, limit: int | None = None) -> int:
- recovery_limit = settings.session_evaluation_recovery_limit if limit is None else limit
+ recovery_limit = (
+ settings.session_evaluation_recovery_limit if limit is None else limit
+ )
if recovery_limit <= 0:
return 0
stale_after_seconds = (
- _session_evaluation_timeout_seconds()
- + MISSING_SESSION_EVALUATION_GRACE_SECONDS
+ _session_evaluation_timeout_seconds() + MISSING_SESSION_EVALUATION_GRACE_SECONDS
)
- candidates, durable = await session_persistence.list_sessions_missing_session_evaluation(
+ (
+ candidates,
+ durable,
+ ) = await session_persistence.list_sessions_missing_session_evaluation(
older_than_seconds=stale_after_seconds,
limit=recovery_limit,
)
@@ -892,9 +1002,7 @@ async def _load_learner_sessions(
if not durable:
require_runtime_fallback_allowed("session list")
sessions = [
- sess
- for sess in store.list()
- if sess.learner_id == principal.user_id
+ sess for sess in store.list() if sess.learner_id == principal.user_id
]
sessions.sort(key=lambda sess: sess.created_at, reverse=True)
return sessions, durable
@@ -917,7 +1025,9 @@ async def _review_ready_map(
sessions: list[InProcSession],
principal: Principal,
) -> dict[str, bool]:
- results = await asyncio.gather(*[_review_ready(sess, principal) for sess in sessions])
+ results = await asyncio.gather(
+ *[_review_ready(sess, principal) for sess in sessions]
+ )
return {sess.session_id: ready for sess, ready in zip(sessions, results)}
@@ -970,7 +1080,9 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR
sess,
review_ready=await _review_ready(sess, principal),
archived=archive_record is not None,
- archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None,
+ archived_at=_iso(float(archived_at))
+ if isinstance(archived_at, (int, float))
+ else None,
)
)
@@ -1042,7 +1154,9 @@ async def archive_session(
allow_ended=True,
)
if not sess.ended:
- raise HTTPException(status.HTTP_409_CONFLICT, detail="active sessions cannot be archived")
+ raise HTTPException(
+ status.HTTP_409_CONFLICT, detail="active sessions cannot be archived"
+ )
record, durable = await session_persistence.set_session_archived(
session_id=sess.session_id,
learner_id=principal.user_id,
@@ -1053,7 +1167,9 @@ async def archive_session(
sess,
principal,
archived=True,
- archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None,
+ archived_at=_iso(float(archived_at))
+ if isinstance(archived_at, (int, float))
+ else None,
source="database" if durable else "runtime",
)
@@ -1084,7 +1200,9 @@ async def restore_archived_session(
)
-@router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED)
+@router.post(
+ "", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED
+)
async def start_session(
body: SessionStartRequest,
principal: CurrentPrincipal,
@@ -1102,14 +1220,18 @@ async def start_session(
detail="persona catalog database unavailable",
) from exc
if catalog_persona is None:
- raise HTTPException(status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}")
+ raise HTTPException(
+ status.HTTP_404_NOT_FOUND, detail=f"unknown persona {body.persona_code}"
+ )
card = catalog_persona.card
case_context = await session_persistence.get_case_context(
learner_id=principal.user_id,
persona_id=catalog_persona.persona_id,
)
- recall = await _build_seed_recall(case_id=case_context.case_id if case_context else None)
+ recall = await _build_seed_recall(
+ case_id=case_context.case_id if case_context else None
+ )
session_no = (case_context.last_session_no + 1) if case_context else 1
st = state_machine.init_state(
params=card.openness_params(),
@@ -1117,6 +1239,7 @@ async def start_session(
)
carry_rapport = st.rapport_credit
+ goal_stages = [str(stage) for stage in body.goal_stages]
sess = await session_persistence.create_session(
learner_id=principal.user_id,
card=card,
@@ -1127,6 +1250,7 @@ async def start_session(
persona_id=catalog_persona.persona_id,
persona_version=catalog_persona.version,
case_id=case_context.case_id if case_context else None,
+ goal_stages=goal_stages,
)
degraded = catalog_persona.degraded or sess is None
if sess is None:
@@ -1138,6 +1262,7 @@ async def start_session(
state=st,
session_no=session_no,
carry_rapport=carry_rapport,
+ goal_stages=goal_stages,
)
else:
store.put(sess)
@@ -1155,6 +1280,10 @@ async def start_session(
effective_openness=round(st.effective_openness, 4),
recall_summary=recall.recall_summary,
degraded=degraded,
+ started_at=_iso(sess.created_at) or "",
+ goal_stages=body.goal_stages,
+ duration_limit_seconds=settings.session_duration_minutes * 60,
+ warning_before_end_seconds=settings.session_warning_minutes * 60,
)
@@ -1169,7 +1298,10 @@ async def get_session_review(
principal,
include_turn_evaluation=True,
)
- evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
+ (
+ evaluation_record,
+ evaluation_durable,
+ ) = await session_persistence.load_session_evaluation(
session_id,
review_principal,
)
@@ -1196,6 +1328,7 @@ async def get_session_review(
)
)
+
@router.post("/{session_id}/share", response_model=SessionShareResponse)
async def create_session_share(
session_id: str,
@@ -1211,7 +1344,9 @@ async def create_session_share(
include_turn_evaluation=True,
)
if not sess.ended:
- raise HTTPException(status.HTTP_409_CONFLICT, detail="session must be ended before sharing")
+ raise HTTPException(
+ status.HTTP_409_CONFLICT, detail="session must be ended before sharing"
+ )
review = await get_session_review(session_id, principal)
token = secrets.token_urlsafe(32)
@@ -1228,7 +1363,14 @@ async def create_session_share(
detail="session share persistence unavailable",
)
created_at = saved.get("created_at")
- created_label = _iso(created_at if isinstance(created_at, (int, float)) else datetime.now().timestamp()) or ""
+ created_label = (
+ _iso(
+ created_at
+ if isinstance(created_at, (int, float))
+ else datetime.now().timestamp()
+ )
+ or ""
+ )
return SessionShareResponse(
shareUrl=_public_share_url(request, token),
title=str(payload["title"]),
@@ -1334,6 +1476,11 @@ async def submit_turn(
crisis_resource=result.crisis_resource,
conversation_stopped=result.conversation_stopped,
output_error=result.output_error,
+ progress=build_session_progress(
+ result.state_after,
+ prev_rapport_credit=sess.prev_rapport_credit,
+ goal_stages=list(sess.goal_stages or []),
+ ),
)
@@ -1345,17 +1492,28 @@ async def list_live_coach_history(
"""현재 회기에서 학습자에게 실제로 전달된 라이브 코칭 이력을 반환한다."""
principal = _ensure_learner(principal)
await _load_session_or_404(session_id, principal)
- events, durable = await session_persistence.list_live_coach_events(session_id, principal)
- quota, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
- credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(
+ events, durable = await session_persistence.list_live_coach_events(
+ session_id, principal
+ )
+ quota, quota_durable = await session_persistence.get_live_coach_quota(
+ session_id, principal
+ )
+ (
+ credit_events,
+ credit_durable,
+ ) = await session_persistence.list_live_coach_credit_events(
session_id,
principal,
)
return LiveCoachHistoryResponse(
- source="database" if durable and quota_durable and credit_durable else "runtime",
+ source="database"
+ if durable and quota_durable and credit_durable
+ else "runtime",
quota=live_coach.LiveCoachQuota(**quota),
events=[live_coach.LiveCoachEvent(**event) for event in events],
- credit_events=[live_coach.LiveCoachCreditEvent(**event) for event in credit_events],
+ credit_events=[
+ live_coach.LiveCoachCreditEvent(**event) for event in credit_events
+ ],
)
@@ -1382,6 +1540,16 @@ async def live_coach_turn(
stage=stage,
theory_mode=sess.theory_mode,
)
+ prior_events, _ = await session_persistence.list_live_coach_events(
+ session_id, principal
+ )
+ prior_coach = [
+ {
+ "title": str((event.get("suggestion") or {}).get("title") or ""),
+ "focus": str((event.get("suggestion") or {}).get("focus") or ""),
+ }
+ for event in prior_events[-2:]
+ ]
item = live_coach.LiveCoachInput(
session_id=sess.session_id,
turn_seq=turn_seq,
@@ -1394,6 +1562,8 @@ async def live_coach_turn(
client_reply=body.client_reply,
recent_turns=sess.recent_turns(k=8, visible_to=LEARNER_VISIBLE_AI_ROLE),
evaluation=_latest_turn_evaluation(sess, body.turn_seq),
+ goal_stages=list(sess.goal_stages or []),
+ prior_coach=prior_coach,
)
suggestion = await live_coach.generate_live_coaching(
item,
@@ -1416,8 +1586,13 @@ async def live_coach_turn(
status_code=status.HTTP_409_CONFLICT,
detail="live coach credit exhausted",
) from exc
- quota_after, quota_durable = await session_persistence.get_live_coach_quota(session_id, principal)
- credit_events, credit_durable = await session_persistence.list_live_coach_credit_events(session_id, principal)
+ quota_after, quota_durable = await session_persistence.get_live_coach_quota(
+ session_id, principal
+ )
+ (
+ credit_events,
+ credit_durable,
+ ) = await session_persistence.list_live_coach_credit_events(session_id, principal)
turn_credit_events = [
live_coach.LiveCoachCreditEvent(**event)
for event in credit_events
@@ -1425,7 +1600,9 @@ async def live_coach_turn(
]
return suggestion.model_copy(
update={
- "persistence_source": "database" if coach_event_durable and quota_durable and credit_durable else "runtime",
+ "persistence_source": "database"
+ if coach_event_durable and quota_durable and credit_durable
+ else "runtime",
"quota": live_coach.LiveCoachQuota(**quota_after),
"credit_events": turn_credit_events[-2:],
}
@@ -1462,10 +1639,20 @@ async def stream_turn(
final_reply += text
yield {"event": "token", "data": text}
elif ev.event == "done":
- data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
+ data = {
+ **ev.data,
+ "stage": _stage_label(ctx.state_after.stage),
+ "progress": build_session_progress(
+ ctx.state_after,
+ prev_rapport_credit=sess.prev_rapport_credit,
+ goal_stages=list(sess.goal_stages or []),
+ ).model_dump(),
+ }
if not finalized_turn:
evaluation = await _evaluate_stream_turn(ctx, final_reply)
- result = _stream_result_from_done(ctx, final_reply, data, evaluation)
+ result = _stream_result_from_done(
+ ctx, final_reply, data, evaluation
+ )
await turn_runtime.finalize_completed_turn(
sess,
ctx,
@@ -1473,7 +1660,10 @@ async def stream_turn(
context_prefix="session",
)
finalized_turn = True
- yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
+ yield {
+ "event": "done",
+ "data": json.dumps(data, ensure_ascii=False),
+ }
elif (
ev.event == "safety"
and bool(ev.data.get("conversation_stopped"))
@@ -1484,14 +1674,18 @@ async def stream_turn(
safety_data = {
"session_id": ctx.session_id,
"stage": _stage_label(ctx.state_after.stage),
- "effective_openness": round(ctx.state_after.effective_openness, 4),
+ "effective_openness": round(
+ ctx.state_after.effective_openness, 4
+ ),
"turn_seq": ctx.state_after.turn_seq,
"safety_flagged": True,
"crisis_kind": ctx.crisis.kind.value,
"crisis_resource": ev.data.get("crisis_resource"),
"conversation_stopped": True,
}
- result = _stream_result_from_done(ctx, final_reply, safety_data, None)
+ result = _stream_result_from_done(
+ ctx, final_reply, safety_data, None
+ )
await turn_runtime.finalize_completed_turn(
sess,
ctx,
@@ -1499,16 +1693,25 @@ async def stream_turn(
context_prefix="session",
)
finalized_turn = True
- yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
+ yield {
+ "event": ev.event,
+ "data": json.dumps(ev.data, ensure_ascii=False),
+ }
else:
- yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
+ yield {
+ "event": ev.event,
+ "data": json.dumps(ev.data, ensure_ascii=False),
+ }
now = asyncio.get_running_loop().time()
if now - last_beat >= settings.sse_heartbeat_seconds:
yield {"event": "ping", "data": "{}"}
last_beat = now
except Exception as exc:
- yield {"event": "error", "data": json.dumps({"detail": str(exc)}, ensure_ascii=False)}
+ yield {
+ "event": "error",
+ "data": json.dumps({"detail": str(exc)}, ensure_ascii=False),
+ }
return
return EventSourceResponse(event_generator())
@@ -1536,8 +1739,7 @@ async def end_session(
)
await _end_persisted_session(sess, carry)
- _RECALL_CACHE.pop(session_id, None)
- _KB_CUES_CACHE.pop(session_id, None)
+ invalidate_session_context_cache(session_id)
if not was_ended:
_schedule_session_evaluation(sess)
diff --git a/apps/api/app/routes/users.py b/apps/api/app/routes/users.py
index cd4fa26..e7c81eb 100644
--- a/apps/api/app/routes/users.py
+++ b/apps/api/app/routes/users.py
@@ -22,6 +22,7 @@ from ..config import settings
from ..db import acquire, get_pool
from ..deps import CurrentPrincipal, Role
from ..runtime_policy import require_runtime_fallback_allowed
+from ..services.phase3_kpi_contract import PREPOST_MEASURE_NAMES, PREPOST_TIMEPOINTS
from ..services.support_tickets import support_ticket_fingerprint
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
@@ -37,13 +38,6 @@ AVATAR_CONTENT_TYPES = {
}
DEFAULT_PREPOST_PILOT_ID = "phase3-pilot-draft"
DEFAULT_PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28"
-PREPOST_MEASURE_NAMES = (
- "self_efficacy",
- "skill_proficiency",
- "training_satisfaction",
-)
-PREPOST_TIMEPOINTS = ("pre", "post")
-
TERMS_BODY = """Vignette 서비스 이용약관 초안
1. 목적
@@ -193,7 +187,9 @@ class UserPreferencesResponse(BaseModel):
theme: str = "system"
voice_preset_id: str = "soft-young-fem"
voice_rate: float = 1.0
- notifications: NotificationPreferences = Field(default_factory=NotificationPreferences)
+ notifications: NotificationPreferences = Field(
+ default_factory=NotificationPreferences
+ )
class UserPreferencesPatch(BaseModel):
@@ -221,7 +217,9 @@ TicketCategory = Literal[
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
TicketStatus = Literal["open", "triaged", "in_progress", "resolved", "closed"]
-PrepostMeasureName = Literal["self_efficacy", "skill_proficiency", "training_satisfaction"]
+PrepostMeasureName = Literal[
+ "self_efficacy", "skill_proficiency", "training_satisfaction"
+]
PrepostTimepoint = Literal["pre", "post"]
@@ -386,7 +384,9 @@ def _preferences_from_row(row) -> UserPreferencesResponse:
theme=row["theme"],
voice_preset_id=_normalize_voice_preset(row["voice_preset_id"]),
voice_rate=float(row["voice_rate"]),
- notifications=NotificationPreferences.model_validate(row["notifications"] or {}),
+ notifications=NotificationPreferences.model_validate(
+ row["notifications"] or {}
+ ),
)
@@ -420,7 +420,9 @@ def _validated_avatar_extension(content_type: str, content: bytes) -> str:
ext, magic = AVATAR_CONTENT_TYPES[normalized]
if normalized == "image/webp":
if not (content.startswith(magic) and content[8:12] == b"WEBP"):
- raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
+ raise HTTPException(
+ status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file"
+ )
elif not content.startswith(magic):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
return ext
@@ -484,16 +486,28 @@ async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
@router.patch("/me", response_model=UserProfileResponse)
-async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
+async def patch_me(
+ body: UserProfilePatch, principal: CurrentPrincipal
+) -> UserProfileResponse:
profile = await _profile_for(principal)
updated = await update_managed_user(
principal.user_id,
ManagedUserPatch(
- display_name=body.display_name if body.display_name is not None else profile.display_name,
- affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
- legal_name=body.legal_name if body.legal_name is not None else profile.legal_name,
- department=body.department if body.department is not None else profile.department,
- grade_level=body.grade_level if body.grade_level is not None else profile.grade_level,
+ display_name=body.display_name
+ if body.display_name is not None
+ else profile.display_name,
+ affiliation=body.affiliation
+ if body.affiliation is not None
+ else profile.affiliation,
+ legal_name=body.legal_name
+ if body.legal_name is not None
+ else profile.legal_name,
+ department=body.department
+ if body.department is not None
+ else profile.department,
+ grade_level=body.grade_level
+ if body.grade_level is not None
+ else profile.grade_level,
phone=body.phone if body.phone is not None else profile.phone,
contact_address=(
body.contact_address
@@ -506,7 +520,9 @@ async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserP
if body.self_introduction is not None
else profile.self_introduction
),
- avatar_url=body.avatar_url if body.avatar_url is not None else profile.avatar_url,
+ avatar_url=body.avatar_url
+ if body.avatar_url is not None
+ else profile.avatar_url,
),
)
if updated is None:
@@ -525,7 +541,9 @@ async def upload_my_avatar(
if not content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
if len(content) > AVATAR_MAX_BYTES:
- raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large")
+ raise HTTPException(
+ status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large"
+ )
ext = _validated_avatar_extension(content_type, content)
root = _upload_root()
@@ -597,7 +615,9 @@ async def create_support_ticket(
) -> UserSupportTicketResponse:
profile = await _profile_for(principal)
try:
- async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
+ async with acquire(
+ role=principal.role.value, user_id=principal.user_id
+ ) as conn:
subject = body.subject.strip()
ticket_body = body.body.strip()
source_path = body.source_path.strip()
@@ -725,7 +745,9 @@ async def _support_tickets_for_user(
resolution_note=row["resolution_note"] or "",
created_at=float(row["created_at"] or 0.0),
updated_at=float(row["updated_at"] or 0.0),
- resolved_at=float(row["resolved_at"]) if row["resolved_at"] is not None else None,
+ resolved_at=float(row["resolved_at"])
+ if row["resolved_at"] is not None
+ else None,
)
for row in rows
],
@@ -741,11 +763,13 @@ async def list_my_support_tickets(
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
- detail="support ticket persistence unavailable",
+ detail="support ticket persistence unavailable",
) from exc
-def _normalized_prepost_score(raw_score: float, min_score: float, max_score: float) -> float:
+def _normalized_prepost_score(
+ raw_score: float, min_score: float, max_score: float
+) -> float:
if max_score <= min_score:
return 0.0
return round(((raw_score - min_score) / (max_score - min_score)) * 100.0, 3)
@@ -803,7 +827,8 @@ async def _prepost_measures_for_user(
pairs = {
item.measure_name
for item in measures
- if {m.timepoint for m in measures if m.measure_name == item.measure_name} == {"pre", "post"}
+ if {m.timepoint for m in measures if m.measure_name == item.measure_name}
+ == {"pre", "post"}
}
return UserPrepostMeasuresResponse(
source="database",
@@ -820,7 +845,9 @@ async def _prepost_measures_for_user(
@router.get("/me/prepost-measures", response_model=UserPrepostMeasuresResponse)
async def list_my_prepost_measures(
principal: CurrentPrincipal,
- pilot_id: str = Query(default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80),
+ pilot_id: str = Query(
+ default=DEFAULT_PREPOST_PILOT_ID, min_length=1, max_length=80
+ ),
) -> UserPrepostMeasuresResponse:
try:
return await _prepost_measures_for_user(principal, pilot_id=pilot_id)
@@ -837,9 +864,13 @@ async def upsert_my_prepost_measure(
principal: CurrentPrincipal,
) -> UserPrepostMeasureItem:
pilot_id = body.pilot_id.strip() or DEFAULT_PREPOST_PILOT_ID
- instrument_version = body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION
+ instrument_version = (
+ body.instrument_version.strip() or DEFAULT_PREPOST_INSTRUMENT_VERSION
+ )
try:
- async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
+ async with acquire(
+ role=principal.role.value, user_id=principal.user_id
+ ) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.learner_prepost_measure (
@@ -982,7 +1013,9 @@ async def patch_preferences(
if body.voice_preset_id is not None
else current_prefs.voice_preset_id
),
- voice_rate=body.voice_rate if body.voice_rate is not None else current_prefs.voice_rate,
+ voice_rate=body.voice_rate
+ if body.voice_rate is not None
+ else current_prefs.voice_rate,
notifications=(
body.notifications
if body.notifications is not None
diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py
index b8b02ff..3a06f8a 100644
--- a/apps/api/app/routes/voice.py
+++ b/apps/api/app/routes/voice.py
@@ -16,6 +16,7 @@ from __future__ import annotations
import json
import hashlib
import time
+from dataclasses import dataclass, field as dataclass_field
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect, HTTPException, status
@@ -49,6 +50,42 @@ class VoiceSpeechRequest(BaseModel):
session_id: str = Field(min_length=1, max_length=80)
turn_seq: int = Field(ge=1)
+
+@dataclass(frozen=True, slots=True)
+class VoiceSessionContext:
+ session_id: str
+ principal: Principal
+ voice_preset: VoicePreset
+
+
+@dataclass(frozen=True, slots=True)
+class VoiceProsody:
+ audio_ref: str | None = None
+ duration_s: float | None = None
+ silence_ms: int | None = None
+ speech_rate: float | None = None
+ barge_in: bool | None = None
+ provider_events: list[dict[str, object]] = dataclass_field(default_factory=list)
+
+
+@dataclass(frozen=True, slots=True)
+class VoiceTurnInput:
+ learner_text: str
+ prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
+
+
+@dataclass(frozen=True, slots=True)
+class VoiceAudioInput:
+ audio: bytes
+ fmt: str | None
+ sample_rate: int | None = None
+ channels: int | None = None
+ sample_width: int | None = None
+ audio_started_at: float | None = None
+ audio_ended_at: float | None = None
+ prosody: VoiceProsody = dataclass_field(default_factory=VoiceProsody)
+
+
# WebSocket close codes.
WS_CLOSE_DEGRADED = 1011
WS_CLOSE_BAD_REQUEST = 1008
@@ -112,6 +149,8 @@ def _is_turn_persistence_unavailable(exc: Exception) -> bool:
return False
detail = str(exc.detail or "")
return "turn append" in detail and "persistence unavailable" in detail
+
+
_PROVIDER_EVENT_TYPE_FIELDS = ("event_type", "type", "kind", "label")
@@ -132,7 +171,9 @@ async def voice_health() -> JSONResponse:
@router.post("/speech")
-async def voice_speech(body: VoiceSpeechRequest, principal: CurrentPrincipal) -> Response:
+async def voice_speech(
+ body: VoiceSpeechRequest, principal: CurrentPrincipal
+) -> Response:
"""Synthesize the persisted client reply for a completed text turn.
The browser sends only session/turn identifiers. The server reloads the
@@ -167,7 +208,9 @@ async def voice_speech(body: VoiceSpeechRequest, principal: CurrentPrincipal) ->
turn_runtime.SessionAccessError.FORBIDDEN: status.HTTP_403_FORBIDDEN,
turn_runtime.SessionAccessError.ENDED: status.HTTP_409_CONFLICT,
}.get(err, status.HTTP_404_NOT_FOUND)
- raise HTTPException(status_code=status_code, detail=f"voice session {err or 'not_found'}")
+ raise HTTPException(
+ status_code=status_code, detail=f"voice session {err or 'not_found'}"
+ )
text = _client_turn_text_for_speech(sess, body.turn_seq)
if text is None:
@@ -222,13 +265,17 @@ async def voice_ws(websocket: WebSocket) -> None:
# Authenticate the same server-side browser session used by REST routes.
principal = await _principal_from_websocket(websocket)
if principal is None:
- await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": "not authenticated"}
+ )
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
if principal.role != Role.LEARNER and principal.can_access_role(Role.LEARNER):
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
- await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": "only learners can use voice"}
+ )
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
@@ -244,7 +291,9 @@ async def voice_ws(websocket: WebSocket) -> None:
websocket,
{
"type": "degraded",
- "reason": bind_meta.get("degraded_reason", "voice session binding degraded"),
+ "reason": bind_meta.get(
+ "degraded_reason", "voice session binding degraded"
+ ),
**bind_meta,
},
)
@@ -294,12 +343,17 @@ async def voice_ws(websocket: WebSocket) -> None:
receiving = True
audio_started_at = time.monotonic()
audio_buf.clear()
- await _safe_send_json(websocket, {"type": "state", "state": "listening"})
+ await _safe_send_json(
+ websocket, {"type": "state", "state": "listening"}
+ )
audio_buf.extend(msg["bytes"])
if len(audio_buf) > _MAX_AUDIO_BYTES:
await _safe_send_json(
websocket,
- {"type": "error", "detail": "audio too large; please send a shorter utterance"},
+ {
+ "type": "error",
+ "detail": "audio too large; please send a shorter utterance",
+ },
)
audio_buf.clear()
receiving = False
@@ -312,7 +366,9 @@ async def voice_ws(websocket: WebSocket) -> None:
try:
ctrl = json.loads(text)
except (json.JSONDecodeError, TypeError):
- await _safe_send_json(websocket, {"type": "error", "detail": "invalid control json"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": "invalid control json"}
+ )
continue
ctype = ctrl.get("type")
@@ -324,30 +380,44 @@ async def voice_ws(websocket: WebSocket) -> None:
audio_channels = _safe_int(ctrl.get("channels"))
audio_sample_width = _safe_int(ctrl.get("sample_width"))
audio_buf.clear()
- await _safe_send_json(websocket, {"type": "state", "state": "listening"})
+ await _safe_send_json(
+ websocket, {"type": "state", "state": "listening"}
+ )
elif ctype == "audio_end":
receiving = False
audio_ended_at = time.monotonic()
silence_ms = _safe_int(ctrl.get("silence_ms"))
- if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None:
- silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
+ if (
+ silence_ms is None
+ and last_audio_end_at is not None
+ and audio_started_at is not None
+ ):
+ silence_ms = max(
+ 0, int((audio_started_at - last_audio_end_at) * 1000)
+ )
end_format = _safe_str(ctrl.get("format")) or audio_format
await _handle_utterance(
websocket,
- session_id=session_id,
- principal=principal,
- voice_preset=voice_preset,
- audio=bytes(audio_buf),
- fmt=end_format,
- sample_rate=_safe_int(ctrl.get("sample_rate")) or audio_sample_rate,
- channels=_safe_int(ctrl.get("channels")) or audio_channels,
- sample_width=_safe_int(ctrl.get("sample_width")) or audio_sample_width,
- audio_started_at=audio_started_at,
- audio_ended_at=audio_ended_at,
- silence_ms=silence_ms,
- barge_in=_safe_bool(ctrl.get("barge_in")),
- provider_events=_safe_provider_events(ctrl.get("provider_events")),
+ VoiceSessionContext(session_id, principal, voice_preset),
+ VoiceAudioInput(
+ audio=bytes(audio_buf),
+ fmt=end_format,
+ sample_rate=_safe_int(ctrl.get("sample_rate"))
+ or audio_sample_rate,
+ channels=_safe_int(ctrl.get("channels")) or audio_channels,
+ sample_width=_safe_int(ctrl.get("sample_width"))
+ or audio_sample_width,
+ audio_started_at=audio_started_at,
+ audio_ended_at=audio_ended_at,
+ prosody=VoiceProsody(
+ silence_ms=silence_ms,
+ barge_in=_safe_bool(ctrl.get("barge_in")),
+ provider_events=_safe_provider_events(
+ ctrl.get("provider_events")
+ ),
+ ),
+ ),
)
last_audio_end_at = audio_ended_at
audio_started_at = None
@@ -365,10 +435,8 @@ async def voice_ws(websocket: WebSocket) -> None:
if learner_text:
await _run_turn_and_speak(
websocket,
- session_id=session_id,
- principal=principal,
- voice_preset=voice_preset,
- learner_text=learner_text,
+ VoiceSessionContext(session_id, principal, voice_preset),
+ VoiceTurnInput(learner_text=learner_text),
)
elif ctype == "stt_result":
@@ -412,7 +480,9 @@ async def voice_ws(websocket: WebSocket) -> None:
)
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
else:
- await _safe_send_json(websocket, {"type": "error", "detail": f"voice ws error: {e}"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": f"voice ws error: {e}"}
+ )
finally:
await _safe_close(websocket)
@@ -431,7 +501,11 @@ async def _handle_stt_result_control(
learner_text = str(ctrl.get("text") or "").strip()
transcript_final = _safe_bool(ctrl.get("final"))
silence_ms = _safe_int(ctrl.get("silence_ms"))
- if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None:
+ if (
+ silence_ms is None
+ and last_audio_end_at is not None
+ and audio_started_at is not None
+ ):
silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
provider_events = _safe_provider_events(ctrl.get("provider_events"))
decision = voice_svc.assess_end_of_turn(
@@ -456,52 +530,49 @@ async def _handle_stt_result_control(
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
await _safe_send_json(
websocket,
- {"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
+ {
+ "type": "transcript",
+ "text": learner_text,
+ "final": True,
+ "speaker": "counselor",
+ },
)
await _run_turn_and_speak(
websocket,
- session_id=session_id,
- principal=principal,
- voice_preset=voice_preset,
- learner_text=learner_text,
- duration_s=_elapsed_seconds(audio_started_at, audio_ended_at),
- silence_ms=decision.silence_ms,
- barge_in=_safe_bool(ctrl.get("barge_in")),
- provider_events=provider_events,
+ VoiceSessionContext(session_id, principal, voice_preset),
+ VoiceTurnInput(
+ learner_text=learner_text,
+ prosody=VoiceProsody(
+ duration_s=_elapsed_seconds(audio_started_at, audio_ended_at),
+ silence_ms=decision.silence_ms,
+ barge_in=_safe_bool(ctrl.get("barge_in")),
+ provider_events=provider_events,
+ ),
+ ),
)
async def _handle_utterance(
websocket: WebSocket,
- *,
- session_id: str,
- principal: Principal,
- voice_preset: VoicePreset,
- audio: bytes,
- fmt: Optional[str],
- sample_rate: int | None = None,
- channels: int | None = None,
- sample_width: int | None = None,
- audio_started_at: float | None = None,
- audio_ended_at: float | None = None,
- silence_ms: int | None = None,
- barge_in: bool | None = None,
- provider_events: list[dict[str, object]] | None = None,
+ context: VoiceSessionContext,
+ utterance: VoiceAudioInput,
) -> None:
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
- if not audio:
- await _safe_send_json(websocket, {"type": "transcript", "text": "", "final": True})
+ if not utterance.audio:
+ await _safe_send_json(
+ websocket, {"type": "transcript", "text": "", "final": True}
+ )
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
# STT begins after the learner stops speaking.
await _safe_send_json(websocket, {"type": "state", "state": "thinking"})
upload_audio, upload_fmt = _normalize_audio_upload(
- audio,
- fmt=fmt,
- sample_rate=sample_rate,
- channels=channels,
- sample_width=sample_width,
+ utterance.audio,
+ fmt=utterance.fmt,
+ sample_rate=utterance.sample_rate,
+ channels=utterance.channels,
+ sample_width=utterance.sample_width,
)
filename, content_type = _audio_meta(upload_fmt)
try:
@@ -513,18 +584,30 @@ async def _handle_utterance(
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
except Exception as e:
- await _safe_send_json(websocket, {"type": "error", "detail": f"STT failed: {e}"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": f"STT failed: {e}"}
+ )
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
learner_text = stt.text
audio_ref = _voice_audio_ref(upload_audio, upload_fmt)
- duration_s = stt.duration or _elapsed_seconds(audio_started_at, audio_ended_at)
+ duration_s = stt.duration or _elapsed_seconds(
+ utterance.audio_started_at, utterance.audio_ended_at
+ )
speech_rate = _estimate_speech_rate(learner_text, duration_s)
- provider_events = _merge_provider_events(provider_events, getattr(stt, "provider_events", []))
+ provider_events = _merge_provider_events(
+ utterance.prosody.provider_events,
+ getattr(stt, "provider_events", []),
+ )
await _safe_send_json(
websocket,
- {"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
+ {
+ "type": "transcript",
+ "text": learner_text,
+ "final": True,
+ "speaker": "counselor",
+ },
)
if not learner_text:
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
@@ -532,47 +615,54 @@ async def _handle_utterance(
await _run_turn_and_speak(
websocket,
- session_id=session_id,
- principal=principal,
- voice_preset=voice_preset,
- learner_text=learner_text,
- audio_ref=audio_ref,
- silence_ms=silence_ms,
- speech_rate=speech_rate,
- barge_in=barge_in,
- provider_events=provider_events,
+ context,
+ VoiceTurnInput(
+ learner_text=learner_text,
+ prosody=VoiceProsody(
+ audio_ref=audio_ref,
+ duration_s=duration_s,
+ silence_ms=utterance.prosody.silence_ms,
+ speech_rate=speech_rate,
+ barge_in=utterance.prosody.barge_in,
+ provider_events=provider_events,
+ ),
+ ),
)
async def _run_turn_and_speak(
websocket: WebSocket,
- *,
- session_id: str,
- principal: Principal,
- voice_preset: VoicePreset,
- learner_text: str,
- audio_ref: str | None = None,
- duration_s: float | None = None,
- silence_ms: int | None = None,
- speech_rate: float | None = None,
- barge_in: bool | None = None,
- provider_events: list[dict[str, object]] | None = None,
+ context: VoiceSessionContext,
+ turn: VoiceTurnInput,
) -> None:
"""Run one counseling turn and stream synthesized client speech."""
+ learner_text = turn.learner_text
+ prosody = turn.prosody
+ speech_rate = prosody.speech_rate
if speech_rate is None:
- speech_rate = _estimate_speech_rate(learner_text, duration_s)
- sess, err = await _load_voice_session(session_id, principal)
+ speech_rate = _estimate_speech_rate(learner_text, prosody.duration_s)
+ sess, err = await _load_voice_session(context.session_id, context.principal)
if sess is None:
- await _safe_send_json(websocket, {"type": "error", "detail": err or "session not found or ended"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": err or "session not found or ended"}
+ )
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
from . import sessions as session_routes
+ if session_routes.session_time_over(sess):
+ await _safe_send_json(
+ websocket,
+ {"type": "error", "detail": "session_time_over"},
+ )
+ await _safe_send_json(websocket, {"type": "state", "state": "idle"})
+ return
+
recall = await session_routes.ensure_recall_context(sess)
- kb_cues = session_routes._KB_CUES_CACHE.get(session_id) or []
+ kb_cues = session_routes.cached_kb_cues(context.session_id)
ctx = orchestrator.prepare_turn(
- session_id=session_id,
+ session_id=context.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
@@ -599,7 +689,9 @@ async def _run_turn_and_speak(
audit_hook=session_persistence.record_llm_call_audit,
)
except EngineError as e:
- await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": f"engine unavailable: {e}"}
+ )
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
return
@@ -617,11 +709,11 @@ async def _run_turn_and_speak(
stage=turn_runtime.stage_label(ctx.state_after.stage),
text=learner_text,
text_masked=ctx.learner_text_masked,
- audio_ref=audio_ref,
- silence_ms=silence_ms,
+ audio_ref=prosody.audio_ref,
+ silence_ms=prosody.silence_ms,
speech_rate=speech_rate,
- barge_in=barge_in,
- provider_events=provider_events or [],
+ barge_in=prosody.barge_in,
+ provider_events=prosody.provider_events,
evaluation=result.evaluation,
),
)
@@ -640,6 +732,11 @@ async def _run_turn_and_speak(
"crisis_kind": result.crisis_kind,
"crisis_resource": result.crisis_resource,
"conversation_stopped": result.conversation_stopped,
+ "progress": session_routes.build_session_progress(
+ result.state_after,
+ prev_rapport_credit=sess.prev_rapport_credit,
+ goal_stages=list(sess.goal_stages or []),
+ ).model_dump(),
},
)
@@ -653,13 +750,13 @@ async def _run_turn_and_speak(
{
"type": "state",
"state": "speaking",
- "voice": voice_preset.openai_voice,
+ "voice": context.voice_preset.openai_voice,
"tts_provider": voice_service.tts_provider(),
},
)
try:
n = 0
- async for ck in voice_service.synthesize_stream(reply, voice_preset):
+ async for ck in voice_service.synthesize_stream(reply, context.voice_preset):
# 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출).
await _safe_send_bytes(websocket, ck.audio)
n += 1
@@ -667,7 +764,9 @@ async def _run_turn_and_speak(
except VoiceUnavailable as e:
await _safe_send_json(websocket, {"type": "degraded", "reason": str(e)})
except Exception as e:
- await _safe_send_json(websocket, {"type": "error", "detail": f"TTS failed: {e}"})
+ await _safe_send_json(
+ websocket, {"type": "error", "detail": f"TTS failed: {e}"}
+ )
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
@@ -732,9 +831,8 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
async def _practice_access_error(principal: Principal) -> str | None:
- if (
- principal.profile_completed_at is None
- and not await user_onboarding_complete(principal.user_id)
+ if principal.profile_completed_at is None and not await user_onboarding_complete(
+ principal.user_id
):
return "onboarding_required"
if principal.consent_at is None and not await user_has_consent(principal.user_id):
@@ -763,7 +861,12 @@ async def _bind_session(
persona_code=sess.persona.code,
explicit_preset=explicit_preset,
)
- return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
+ return (
+ session_id,
+ vp,
+ None,
+ {"degraded": False, "persona_catalog_source": "session"},
+ )
# persona_code session creation is local-dev only. Production uses REST start.
if settings.environment != "dev":
@@ -815,9 +918,13 @@ async def _bind_session(
)
degraded_reasons: list[str] = []
if catalog_persona.degraded:
- degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
+ degraded_reasons.append(
+ "카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다"
+ )
if session_source == "runtime":
- degraded_reasons.append("세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다")
+ degraded_reasons.append(
+ "세션 저장소 연결 전까지 비영구 개발 런타임 기록을 사용합니다"
+ )
bind_meta = {
"degraded": bool(degraded_reasons),
"degraded_reason": "; ".join(degraded_reasons) if degraded_reasons else None,
@@ -906,7 +1013,9 @@ def _normalize_audio_upload(
raise ValueError("pcm sample_width must be 2 bytes")
return _wav_from_pcm16(
audio,
- sample_rate=_bounded_int(sample_rate, default=48000, minimum=8000, maximum=96000),
+ sample_rate=_bounded_int(
+ sample_rate, default=48000, minimum=8000, maximum=96000
+ ),
channels=_bounded_int(channels, default=1, minimum=1, maximum=2),
), "wav"
diff --git a/apps/api/app/runtime_schema.py b/apps/api/app/runtime_schema.py
new file mode 100644
index 0000000..758c1bd
--- /dev/null
+++ b/apps/api/app/runtime_schema.py
@@ -0,0 +1,126 @@
+"""런타임 스키마 준비 상태 정책.
+
+권위 스키마는 ``infra/db/init/*.sql``이다. 앱 기동 중 불완전한 스키마 보정은 로컬 개발
+환경에서만 허용하고, 스테이징과 운영은 owner migration을 요구하며 fail-closed한다.
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from typing import Any, Protocol
+
+from .config import settings
+
+
+class SchemaConnection(Protocol):
+ async def fetchrow(self, query: str, *args: Any) -> Any: ...
+
+
+@dataclass(frozen=True, slots=True)
+class RuntimeSchemaContract:
+ component: str
+ relations: tuple[str, ...]
+ columns: tuple[str, ...] = ()
+ policies: tuple[str, ...] = ()
+
+
+REVIEW_SCHEMA_CONTRACT = RuntimeSchemaContract(
+ component="review/evaluation",
+ relations=(
+ "app.session_evaluation",
+ "app.case_worksheet",
+ "app.live_coach_events",
+ "app.safety_events",
+ "app.session_review_status",
+ "app.session_share_link",
+ "app.session_archive_state",
+ ),
+ columns=(
+ "app.live_coach_events.event_type",
+ "app.live_coach_events.credit_delta",
+ "app.live_coach_events.credit_balance",
+ "app.live_coach_events.reason",
+ "app.session_review_status.worksheet_status",
+ "app.session_review_status.worksheet_note",
+ "app.session_review_status.worksheet_reviewed_at",
+ ),
+ policies=(
+ "app.session_evaluation.p_session_evaluation_select",
+ "app.case_worksheet.p_case_worksheet_select",
+ "app.live_coach_events.p_live_coach_events_select",
+ "app.safety_events.p_safety_events_select",
+ "app.session_review_status.p_session_review_status_select",
+ "app.session_share_link.p_session_share_select",
+ "app.session_archive_state.p_session_archive_select",
+ ),
+)
+
+NOTIFICATION_SCHEMA_CONTRACT = RuntimeSchemaContract(
+ component="notification",
+ relations=("app.notification_event", "app.notification_delivery"),
+ policies=(
+ "app.notification_event.p_notification_event_admin_all",
+ "app.notification_delivery.p_notification_delivery_admin_all",
+ ),
+)
+
+
+async def schema_contract_ready(
+ conn: SchemaConnection,
+ contract: RuntimeSchemaContract,
+) -> bool:
+ row = await conn.fetchrow(
+ """
+ SELECT
+ NOT EXISTS (
+ SELECT 1
+ FROM unnest($1::text[]) AS required(relation_name)
+ WHERE to_regclass(required.relation_name) IS NULL
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM unnest($2::text[]) AS required(qualified_name)
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM information_schema.columns c
+ WHERE c.table_schema = split_part(required.qualified_name, '.', 1)
+ AND c.table_name = split_part(required.qualified_name, '.', 2)
+ AND c.column_name = split_part(required.qualified_name, '.', 3)
+ )
+ )
+ AND NOT EXISTS (
+ SELECT 1
+ FROM unnest($3::text[]) AS required(qualified_name)
+ WHERE NOT EXISTS (
+ SELECT 1
+ FROM pg_policies p
+ WHERE p.schemaname = split_part(required.qualified_name, '.', 1)
+ AND p.tablename = split_part(required.qualified_name, '.', 2)
+ AND p.policyname = split_part(required.qualified_name, '.', 3)
+ )
+ ) AS ready
+ """,
+ list(contract.relations),
+ list(contract.columns),
+ list(contract.policies),
+ )
+ return bool(row and row["ready"])
+
+
+def runtime_schema_bootstrap_required(
+ contract: RuntimeSchemaContract | str,
+ *,
+ ready: bool,
+) -> bool:
+ """로컬 보정 필요 여부를 반환하고, dev 외 환경에서는 불완전 스키마를 차단한다."""
+ if ready:
+ return False
+ component = (
+ contract.component if isinstance(contract, RuntimeSchemaContract) else contract
+ )
+ if settings.environment != "dev":
+ raise RuntimeError(
+ f"{component} runtime DB schema is incomplete; run owner migration/init and "
+ "scripts/check-deploy-preflight.py before starting the API"
+ )
+ return True
diff --git a/apps/api/app/services/dataset_export.py b/apps/api/app/services/dataset_export.py
index 478f116..3fce421 100644
--- a/apps/api/app/services/dataset_export.py
+++ b/apps/api/app/services/dataset_export.py
@@ -52,7 +52,9 @@ PII_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
("email", re.compile(r"\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b", re.I)),
(
"phone",
- re.compile(r"\b(?:\+?82[-. ]?)?(?:0?1[016789]|0[2-9]\d?)[-. ]?\d{3,4}[-. ]?\d{4}\b"),
+ re.compile(
+ r"\b(?:\+?82[-. ]?)?(?:0?1[016789]|0[2-9]\d?)[-. ]?\d{3,4}[-. ]?\d{4}\b"
+ ),
),
("national_id", re.compile(r"\b\d{6}[- ]?[1-4]\d{6}\b")),
("student_id", re.compile(r"\b20\d{2}[- ]?\d{4,8}\b")),
@@ -62,7 +64,12 @@ PII_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
r"(?i)\b(?:api[_-]?key|access[_-]?token|refresh[_-]?token|session[_-]?secret|cookie)\b\s*[:=]\s*\S+"
),
),
- ("secret", re.compile(r"\b(?:sk-[A-Za-z0-9_-]{12,}|AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[A-Za-z0-9-]+)\b")),
+ (
+ "secret",
+ re.compile(
+ r"\b(?:sk-[A-Za-z0-9_-]{12,}|AIza[0-9A-Za-z_-]{20,}|xox[baprs]-[A-Za-z0-9-]+)\b"
+ ),
+ ),
)
@@ -155,7 +162,13 @@ def scan_for_pii(value: Any, path: str = "$") -> list[dict[str, Any]]:
key = str(raw_key)
next_path = f"{path}.{key}"
if key.lower() in BLOCKED_FIELD_NAMES:
- findings.append({"kind": "blocked_field", "path": next_path, "sample": f"<{key.lower()}>"})
+ findings.append(
+ {
+ "kind": "blocked_field",
+ "path": next_path,
+ "sample": f"<{key.lower()}>",
+ }
+ )
findings.extend(scan_for_pii(item, next_path))
return findings
if isinstance(value, list):
@@ -195,11 +208,19 @@ def build_dataset_record(
"speaker": row.get("speaker") or "",
"text_masked": text_masked,
"techniques": json_safe(normalize_json_value(row.get("techniques") or [])),
- "client_states": json_safe(normalize_json_value(row.get("client_states") or [])),
- "feedback_scores": json_safe(normalize_json_value(row.get("feedback_scores") or [])),
- "supervisor_comments": export_safe_supervisor_comments(row.get("supervisor_comments") or []),
+ "client_states": json_safe(
+ normalize_json_value(row.get("client_states") or [])
+ ),
+ "feedback_scores": json_safe(
+ normalize_json_value(row.get("feedback_scores") or [])
+ ),
+ "supervisor_comments": export_safe_supervisor_comments(
+ row.get("supervisor_comments") or []
+ ),
"source_refs": {
- "session_started_at": json_safe(row.get("session_started_at") or row.get("started_at")),
+ "session_started_at": json_safe(
+ row.get("session_started_at") or row.get("started_at")
+ ),
"export_manifest_id": export_manifest_id,
},
"privacy": {
@@ -214,7 +235,14 @@ def write_jsonl(records: Sequence[Mapping[str, Any]], path: Path) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8", newline="\n") as handle:
for record in records:
- handle.write(json.dumps(json_safe(record), ensure_ascii=False, sort_keys=True, separators=(",", ":")))
+ handle.write(
+ json.dumps(
+ json_safe(record),
+ ensure_ascii=False,
+ sort_keys=True,
+ separators=(",", ":"),
+ )
+ )
handle.write("\n")
@@ -226,7 +254,9 @@ def sha256_file(path: Path) -> str:
return digest.hexdigest()
-def cohen_kappa(annotations: Iterable[Mapping[str, Any]], label_key: str) -> float | None:
+def cohen_kappa(
+ annotations: Iterable[Mapping[str, Any]], label_key: str
+) -> float | None:
pairs: list[tuple[Any, Any]] = []
by_item: dict[Any, list[Any]] = defaultdict(list)
for annotation in annotations:
@@ -244,13 +274,18 @@ def cohen_kappa(annotations: Iterable[Mapping[str, Any]], label_key: str) -> flo
observed = sum(1 for left, right in pairs if left == right) / total
left_counts = Counter(left for left, _ in pairs)
right_counts = Counter(right for _, right in pairs)
- expected = sum((left_counts[label] / total) * (right_counts[label] / total) for label in set(left_counts) | set(right_counts))
+ expected = sum(
+ (left_counts[label] / total) * (right_counts[label] / total)
+ for label in set(left_counts) | set(right_counts)
+ )
if math.isclose(1.0, expected):
return 1.0 if math.isclose(1.0, observed) else None
return round((observed - expected) / (1.0 - expected), 4)
-def intraclass_correlation(annotations: Iterable[Mapping[str, Any]], score_key: str) -> float | None:
+def intraclass_correlation(
+ annotations: Iterable[Mapping[str, Any]], score_key: str
+) -> float | None:
by_item: dict[Any, list[float]] = defaultdict(list)
for annotation in annotations:
labels = normalize_json_value(annotation.get("labels") or {})
@@ -275,7 +310,9 @@ def intraclass_correlation(annotations: Iterable[Mapping[str, Any]], score_key:
residual = 0.0
for row_index, row in enumerate(matrix):
for col_index, value in enumerate(row):
- residual += (value - row_means[row_index] - col_means[col_index] + grand_mean) ** 2
+ residual += (
+ value - row_means[row_index] - col_means[col_index] + grand_mean
+ ) ** 2
mse = residual / ((n - 1) * (k - 1))
denominator = msr + (k - 1) * mse + (k * (msc - mse) / n)
if math.isclose(denominator, 0.0):
@@ -284,7 +321,9 @@ def intraclass_correlation(annotations: Iterable[Mapping[str, Any]], score_key:
def infer_source_window(records: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
- starts = [record.get("source_refs", {}).get("session_started_at") for record in records]
+ starts = [
+ record.get("source_refs", {}).get("session_started_at") for record in records
+ ]
starts = [value for value in starts if value]
return {
"started_at": min(starts) if starts else "",
@@ -292,35 +331,39 @@ def infer_source_window(records: Sequence[Mapping[str, Any]]) -> dict[str, Any]:
}
-def build_manifest(
- *,
- export_id: str,
- dataset_name: str,
- export_status: str,
- purpose: str,
- records: Sequence[Mapping[str, Any]],
- jsonl_path: str,
- jsonl_sha256: str,
- pii_findings: Sequence[Mapping[str, Any]],
- participants_included: int,
- participants_excluded: int = 0,
- cohort_id: str = "phase3",
- consent_version: str = "",
- agreement: Mapping[str, Any] | None = None,
- approvals: Mapping[str, str] | None = None,
- known_limitations: Sequence[str] | None = None,
- created_at: datetime | None = None,
-) -> dict[str, Any]:
- if export_status not in EXPORT_STATUSES:
- raise ValueError(f"unsupported export_status: {export_status}")
+@dataclass(frozen=True, slots=True)
+class DatasetManifestInput:
+ """Approved/dry-run dataset artifact metadata crossing the export boundary."""
+
+ export_id: str
+ dataset_name: str
+ export_status: str
+ purpose: str
+ records: Sequence[Mapping[str, Any]]
+ jsonl_path: str
+ jsonl_sha256: str
+ pii_findings: Sequence[Mapping[str, Any]]
+ participants_included: int
+ participants_excluded: int = 0
+ cohort_id: str = "phase3"
+ consent_version: str = ""
+ agreement: Mapping[str, Any] | None = None
+ approvals: Mapping[str, str] | None = None
+ known_limitations: Sequence[str] | None = None
+ created_at: datetime | None = None
+
+
+def build_manifest(spec: DatasetManifestInput) -> dict[str, Any]:
+ if spec.export_status not in EXPORT_STATUSES:
+ raise ValueError(f"unsupported export_status: {spec.export_status}")
agreement_payload = {
"kappa": None,
"icc": None,
"gold_status": "not_gold",
}
- if agreement:
- agreement_payload.update(dict(agreement))
+ if spec.agreement:
+ agreement_payload.update(dict(spec.agreement))
approvals_payload = {
"data_steward": "",
@@ -328,23 +371,27 @@ def build_manifest(
"technical_operator": "",
"approved_at": "",
}
- if approvals:
- approvals_payload.update({key: value for key, value in approvals.items() if value is not None})
+ if spec.approvals:
+ approvals_payload.update(
+ {key: value for key, value in spec.approvals.items() if value is not None}
+ )
- pii_status = "pass" if not pii_findings else "fail"
- limitations = list(known_limitations or [])
- if export_status != APPROVED_EXPORT_STATUS:
- limitations.append("technical dry-run only; data-steward/legal approval is not complete")
- if pii_findings:
+ pii_status = "pass" if not spec.pii_findings else "fail"
+ limitations = list(spec.known_limitations or [])
+ if spec.export_status != APPROVED_EXPORT_STATUS:
+ limitations.append(
+ "technical dry-run only; data-steward/legal approval is not complete"
+ )
+ if spec.pii_findings:
limitations.append("PII scan found records requiring reviewer disposition")
manifest = {
- "export_id": export_id,
- "dataset_name": dataset_name,
- "export_status": export_status,
- "created_at": json_safe(created_at or datetime.now(UTC)),
- "purpose": purpose,
- "source_window": infer_source_window(records),
+ "export_id": spec.export_id,
+ "dataset_name": spec.dataset_name,
+ "export_status": spec.export_status,
+ "created_at": json_safe(spec.created_at or datetime.now(UTC)),
+ "purpose": spec.purpose,
+ "source_window": infer_source_window(spec.records),
"source_tables": [
"app.sessions",
"app.turns",
@@ -356,17 +403,17 @@ def build_manifest(
"ds.export_manifest",
],
"selection_criteria": {
- "cohort_id": cohort_id,
+ "cohort_id": spec.cohort_id,
"min_completed_sessions": 0,
"include_withdrawn": False,
"excluded_safety_scope": ["self_harm_scenario_primary"],
},
"consent_scope": {
- "consent_version": consent_version,
+ "consent_version": spec.consent_version,
"allowed_uses": ["education_quality_review", "recursive_learning_seed"],
"withdrawal_cutoff_applied_at": "",
- "participants_included": participants_included,
- "participants_excluded": participants_excluded,
+ "participants_included": spec.participants_included,
+ "participants_excluded": spec.participants_excluded,
},
"anonymization": {
"participant_key": "pseudonymous export key; no identity map included",
@@ -379,14 +426,14 @@ def build_manifest(
"version": "1",
"ran_at": json_safe(datetime.now(UTC)),
"status": pii_status,
- "findings": [json_safe(finding) for finding in pii_findings],
+ "findings": [json_safe(finding) for finding in spec.pii_findings],
},
"agreement": agreement_payload,
"files": [
{
- "path": jsonl_path,
- "rows": len(records),
- "sha256": jsonl_sha256,
+ "path": spec.jsonl_path,
+ "rows": len(spec.records),
+ "sha256": spec.jsonl_sha256,
"schema": DATASET_ITEM_SCHEMA,
}
],
@@ -411,13 +458,28 @@ def validate_manifest_gate(manifest: Mapping[str, Any]) -> None:
if (agreement.get("icc") or 0) < 0.75:
errors.append("ICC must be >= 0.75")
selection_criteria = manifest.get("selection_criteria") or {}
- if not isinstance(selection_criteria, Mapping) or selection_criteria.get("include_withdrawn") is not False:
+ if (
+ not isinstance(selection_criteria, Mapping)
+ or selection_criteria.get("include_withdrawn") is not False
+ ):
errors.append("include_withdrawn must be false")
consent_scope = manifest.get("consent_scope") or {}
- allowed_uses = consent_scope.get("allowed_uses") if isinstance(consent_scope, Mapping) else None
- if not isinstance(allowed_uses, list) or "recursive_learning_seed" not in allowed_uses:
+ allowed_uses = (
+ consent_scope.get("allowed_uses")
+ if isinstance(consent_scope, Mapping)
+ else None
+ )
+ if (
+ not isinstance(allowed_uses, list)
+ or "recursive_learning_seed" not in allowed_uses
+ ):
errors.append("recursive_learning_seed consent scope is required")
- for key in ("data_steward", "legal_or_privacy_reviewer", "technical_operator", "approved_at"):
+ for key in (
+ "data_steward",
+ "legal_or_privacy_reviewer",
+ "technical_operator",
+ "approved_at",
+ ):
if not str(approvals.get(key) or "").strip():
errors.append(f"approval missing: {key}")
if errors:
diff --git a/apps/api/app/services/evaluation_contract.py b/apps/api/app/services/evaluation_contract.py
new file mode 100644
index 0000000..2e730d2
--- /dev/null
+++ b/apps/api/app/services/evaluation_contract.py
@@ -0,0 +1,25 @@
+"""Shared fast-loop evaluation labels and explicit score projections."""
+
+from __future__ import annotations
+
+from typing import Literal
+
+Appropriateness = Literal["pos", "warn", "neutral"]
+APPROPRIATENESS_VALUES: tuple[Appropriateness, ...] = ("pos", "warn", "neutral")
+
+# Persistence uses the rubric's 1..5 scale. Keep this projection distinct from
+# the learner-growth 0..1 normalization below.
+PERSISTED_APPROPRIATENESS_SCORE_5PT: dict[Appropriateness, float] = {
+ "warn": 1.0,
+ "neutral": 3.0,
+ "pos": 5.0,
+}
+
+# ``neg`` is retained only for legacy stored evaluations; current evaluator
+# output is restricted to APPROPRIATENESS_VALUES.
+GROWTH_APPROPRIATENESS_SCORE_01: dict[str, float] = {
+ "neg": 0.0,
+ "warn": 0.25,
+ "neutral": 0.5,
+ "pos": 1.0,
+}
diff --git a/apps/api/app/services/evaluator.py b/apps/api/app/services/evaluator.py
index dc6cd7d..d616ac7 100644
--- a/apps/api/app/services/evaluator.py
+++ b/apps/api/app/services/evaluator.py
@@ -49,15 +49,16 @@ from ..taxonomy import (
TECHNIQUE_CATEGORY,
TECHNIQUE_KO,
ClientState,
- CommentKind,
Technique,
TechniqueCategory,
speaker_ko_label,
)
from . import guardrail
+from .evaluation_contract import APPROPRIATENESS_VALUES, Appropriateness
+from .llm_audit import LlmAuditHook, generate_with_audit
if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입 힌트 전용.
- from .orchestrator import LlmAuditHook, TurnContext
+ from .orchestrator import TurnContext
# ════════════════════════════════════════════════════════════════════════════
@@ -66,11 +67,12 @@ if TYPE_CHECKING: # 런타임 import 회피(순환·소유권 경계). 타입
# LLM 은 후보로 한글 라벨을 받지만, 코드값(value)을 돌려줄 수도 있어 둘 다 받는다.
_TECHNIQUE_BY_KO: dict[str, Technique] = {ko: t for t, ko in TECHNIQUE_KO.items()}
_TECHNIQUE_BY_CODE: dict[str, Technique] = {t.value: t for t in Technique}
-_CLIENT_STATE_BY_KO: dict[str, ClientState] = {ko: s for s, ko in CLIENT_STATE_KO.items()}
+_CLIENT_STATE_BY_KO: dict[str, ClientState] = {
+ ko: s for s, ko in CLIENT_STATE_KO.items()
+}
_CLIENT_STATE_BY_CODE: dict[str, ClientState] = {s.value: s for s in ClientState}
# 적절성 신호 — fast-loop 의 경량 판단(상태머신 라포 추정과 별개 차원).
-_APPROPRIATENESS = ("pos", "warn", "neutral")
# 의도이탈 심각도 (taxonomy.SupervisorComment.severity 와 동일 어휘).
_SEVERITY = ("minor", "moderate", "major")
@@ -207,17 +209,17 @@ class IntentDeviation(BaseModel):
class TechniqueTag(BaseModel):
"""fast-loop 기법 태그 1건 — taxonomy.Technique 코드 + 한글 + 군집 + 근거."""
- code: str # taxonomy.Technique.value
- label_ko: str # TECHNIQUE_KO
- category: str # TechniqueCategory.value (분포 집계축)
+ code: str # taxonomy.Technique.value
+ label_ko: str # TECHNIQUE_KO
+ category: str # TechniqueCategory.value (분포 집계축)
rationale: Optional[str] = None # 왜 이 기법으로 봤는지(근거 요구)
class ClientStateRead(BaseModel):
"""내담자 상태 '읽기' — 학습자 발화 직후 내담자 응답에서 관측된 상태(읽기 채점 근거)."""
- code: str # taxonomy.ClientState.value
- label_ko: str # CLIENT_STATE_KO
+ code: str # taxonomy.ClientState.value
+ label_ko: str # CLIENT_STATE_KO
rationale: Optional[str] = None
@@ -236,12 +238,14 @@ class TurnEvaluation(BaseModel):
stage: str
techniques: list[TechniqueTag] = Field(default_factory=list)
client_state_read: list[ClientStateRead] = Field(default_factory=list)
- appropriateness: str = "neutral" # pos | warn | neutral
+ appropriateness: Appropriateness = "neutral"
appropriateness_note: Optional[str] = None
intent_deviation: Optional[IntentDeviation] = None # 있을 때만(1급 시민)
- rapport_signal: Optional[float] = None # 평가 AI 가 본 라포 신호(−1~+1, 상태머신 주입 가능)
+ rapport_signal: Optional[float] = (
+ None # 평가 AI 가 본 라포 신호(−1~+1, 상태머신 주입 가능)
+ )
theory_mode: Optional[str] = None
- error: Optional[str] = None # 평가 실패 시 사유(비치명적; None 이면 정상)
+ error: Optional[str] = None # 평가 실패 시 사유(비치명적; None 이면 정상)
def to_hook_dict(self) -> dict[str, Any]:
"""orchestrator.EvalHook 가 기대하는 평가 dict(turns.evaluation 적재용)."""
@@ -251,10 +255,12 @@ class TurnEvaluation(BaseModel):
class TechniqueDistribution(BaseModel):
"""deep-loop 기법 분포 — 군집별 카운트 + 과다/과소 진단."""
- by_category: dict[str, int] = Field(default_factory=dict) # category.value -> count
- by_technique: dict[str, int] = Field(default_factory=dict) # technique.value -> count
+ by_category: dict[str, int] = Field(default_factory=dict) # category.value -> count
+ by_technique: dict[str, int] = Field(
+ default_factory=dict
+ ) # technique.value -> count
total: int = 0
- overused: list[str] = Field(default_factory=list) # 과다 사용 군집(category.value)
+ overused: list[str] = Field(default_factory=list) # 과다 사용 군집(category.value)
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
@@ -266,15 +272,15 @@ class SessionEvaluation(BaseModel):
loop: str = "deep"
session_id: str
- stage: str # 평가 시점 단계(전환 트리거면 from-stage)
- scope: str = "session_end" # 'session_end' | 'stage_transition'
+ stage: str # 평가 시점 단계(전환 트리거면 from-stage)
+ scope: str = "session_end" # 'session_end' | 'stage_transition'
turns_evaluated: int = 0
distribution: TechniqueDistribution = Field(default_factory=TechniqueDistribution)
- strengths: list[str] = Field(default_factory=list) # 잘한 순간(근거 포함 문장)
- improvements: list[str] = Field(default_factory=list) # 개선점(최대 3)
+ strengths: list[str] = Field(default_factory=list) # 잘한 순간(근거 포함 문장)
+ improvements: list[str] = Field(default_factory=list) # 개선점(최대 3)
intent_deviations: list[IntentDeviation] = Field(default_factory=list)
- supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
- supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
+ supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
+ supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
theory_mode: Optional[str] = None
error: Optional[str] = None
@@ -324,7 +330,7 @@ def _fast_schema() -> dict[str, Any]:
"required": ["code"],
},
},
- "appropriateness": {"type": "string", "enum": list(_APPROPRIATENESS)},
+ "appropriateness": {"type": "string", "enum": list(APPROPRIATENESS_VALUES)},
"appropriateness_note": {"type": "string"},
"rapport_signal": {"type": "number", "minimum": -1, "maximum": 1},
"intent_deviation": {
@@ -350,7 +356,11 @@ def _deep_schema() -> dict[str, Any]:
"additionalProperties": False,
"properties": {
"strengths": {"type": "array", "items": {"type": "string"}},
- "improvements": {"type": "array", "items": {"type": "string"}, "maxItems": 3},
+ "improvements": {
+ "type": "array",
+ "items": {"type": "string"},
+ "maxItems": 3,
+ },
"intent_deviations": {
"type": "array",
"items": {
@@ -405,7 +415,9 @@ def _client_state_candidates_block() -> str:
# ─ few-shot 골든셋 예시(data/golden) — 명시적으로 켠 환경에서만 로딩 ─────────
# 골든셋은 학습/평가 보정 자료이지 운영 런타임의 기본 데이터가 아니다.
-_GOLDEN_FEWSHOT_ENABLED = os.environ.get("EVALUATOR_GOLDEN_FEWSHOT_ENABLED", "").lower() in {
+_GOLDEN_FEWSHOT_ENABLED = os.environ.get(
+ "EVALUATOR_GOLDEN_FEWSHOT_ENABLED", ""
+).lower() in {
"1",
"true",
"yes",
@@ -456,7 +468,11 @@ def _fewshot_block() -> str:
techs = ",".join(e.get("techniques", []))
text = (e.get("text") or "").replace("\n", " ")[:70]
rat = next(
- (c.get("text", "") for c in e.get("comments", []) if c.get("kind") == "rationale"),
+ (
+ c.get("text", "")
+ for c in e.get("comments", [])
+ if c.get("kind") == "rationale"
+ ),
"",
)
line = f'- "{text}…" → {techs}'
@@ -482,10 +498,13 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
client_reply_masked = guardrail.mask_pii(client_reply).text_masked
- recent = "\n".join(
- f"{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
- for t in (ctx.memory.recent_turns or [])[-4:]
- ) or "(직전 맥락 없음)"
+ recent = (
+ "\n".join(
+ f"{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
+ for t in (ctx.memory.recent_turns or [])[-4:]
+ )
+ or "(직전 맥락 없음)"
+ )
crisis_note = ""
if ctx.crisis is not None and getattr(ctx.crisis, "escalate", False):
@@ -495,7 +514,8 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
)
system = "\n\n".join(
- p for p in [
+ p
+ for p in [
_EVAL_ROLE,
_technique_candidates_block(),
_client_state_candidates_block(),
@@ -509,7 +529,8 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
"없으면 null. 이 항목은 가장 중요하다 — 무리한 생성 금지, 진짜 이탈만.\n"
"추가로 rapport_signal(−1~+1): 이 발화가 라포에 끼친 방향(공감·반영=+, 조언점프·평가=−)."
),
- ] if p
+ ]
+ if p
)
user = (
@@ -537,11 +558,16 @@ def build_deep_messages(
distribution: "TechniqueDistribution",
) -> list[EngineMessage]:
"""deep-loop 평가 프롬프트(전체 회기 + 코드 집계 분포 + 골든라벨 후보)."""
- transcript = "\n".join(
- f"{t.get('seq', '')}{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
- for t in masked_turns
- ) or "(축어록 없음)"
- dist_lines = ", ".join(f"{k}:{v}" for k, v in distribution.by_category.items()) or "(없음)"
+ transcript = (
+ "\n".join(
+ f"{t.get('seq', '')}{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
+ for t in masked_turns
+ )
+ or "(축어록 없음)"
+ )
+ dist_lines = (
+ ", ".join(f"{k}:{v}" for k, v in distribution.by_category.items()) or "(없음)"
+ )
over = ", ".join(distribution.overused) or "(없음)"
under = ", ".join(distribution.underused) or "(없음)"
@@ -590,8 +616,9 @@ def _parse_intent_deviation(d: Any) -> Optional[IntentDeviation]:
return IntentDeviation(dimension=dim, expected=exp, actual=act, severity=sev)
-def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
- theory: Optional[str]) -> TurnEvaluation:
+def _parse_fast(
+ payload: dict[str, Any], *, turn_seq: int, stage: str, theory: Optional[str]
+) -> TurnEvaluation:
techniques: list[TechniqueTag] = []
for item in payload.get("techniques") or []:
if not isinstance(item, dict):
@@ -604,7 +631,9 @@ def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
code=t.value,
label_ko=TECHNIQUE_KO[t],
category=TECHNIQUE_CATEGORY[t].value,
- rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
+ rationale=(str(item.get("rationale")).strip() or None)
+ if item.get("rationale")
+ else None,
)
)
@@ -619,12 +648,14 @@ def _parse_fast(payload: dict[str, Any], *, turn_seq: int, stage: str,
ClientStateRead(
code=s.value,
label_ko=CLIENT_STATE_KO[s],
- rationale=(str(item.get("rationale")).strip() or None) if item.get("rationale") else None,
+ rationale=(str(item.get("rationale")).strip() or None)
+ if item.get("rationale")
+ else None,
)
)
appro = str(payload.get("appropriateness") or "neutral").strip()
- if appro not in _APPROPRIATENESS:
+ if appro not in APPROPRIATENESS_VALUES:
appro = "neutral"
rapport = payload.get("rapport_signal")
@@ -706,7 +737,9 @@ async def evaluate_turn(
"""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
- base = TurnEvaluation(loop="fast", turn_seq=st.turn_seq, stage=st.stage.value, theory_mode=theory)
+ base = TurnEvaluation(
+ loop="fast", turn_seq=st.turn_seq, stage=st.stage.value, theory_mode=theory
+ )
try:
req = GenerateRequest(
@@ -715,7 +748,7 @@ async def evaluate_turn(
structured_schema=_fast_schema(),
model=_configured_model(settings.evaluator_fast_model),
max_tokens=900,
- temperature=0.2, # 평가는 보수적·재현적으로
+ temperature=0.2, # 평가는 보수적·재현적으로
session_id=ctx.session_id,
metadata={"loop": "fast", "stage": st.stage.value, "turn_seq": st.turn_seq},
)
@@ -723,20 +756,7 @@ async def evaluate_turn(
cached = _evaluator_cache_get(cache_key)
if cached is not None:
return TurnEvaluation.model_validate(cached)
- started = time.perf_counter()
- resp = await engine.generate(req)
- latency_ms = int((time.perf_counter() - started) * 1000)
- await _record_llm_audit(
- audit_hook,
- session_id=ctx.session_id,
- provider=resp.provider,
- model=resp.model,
- tokens_in=resp.tokens_in,
- tokens_out=resp.tokens_out,
- cost_usd=resp.cost_usd,
- inference_geo=resp.inference_geo,
- latency_ms=latency_ms,
- )
+ resp = await generate_with_audit(engine, req, audit_hook)
except EngineError:
base.error = "engine_error"
return base
@@ -749,7 +769,9 @@ async def evaluate_turn(
base.error = "no_structured_output"
return base
try:
- result = _parse_fast(payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory)
+ result = _parse_fast(
+ payload, turn_seq=st.turn_seq, stage=st.stage.value, theory=theory
+ )
_evaluator_cache_put(cache_key, result.model_dump())
return result
except Exception: # 파싱 방어
@@ -806,20 +828,7 @@ async def evaluate_session(
cached = _evaluator_cache_get(cache_key)
if cached is not None:
return SessionEvaluation.model_validate(cached)
- started = time.perf_counter()
- resp = await engine.generate(req)
- latency_ms = int((time.perf_counter() - started) * 1000)
- await _record_llm_audit(
- audit_hook,
- session_id=session_id,
- provider=resp.provider,
- model=resp.model,
- tokens_in=resp.tokens_in,
- tokens_out=resp.tokens_out,
- cost_usd=resp.cost_usd,
- inference_geo=resp.inference_geo,
- latency_ms=latency_ms,
- )
+ resp = await generate_with_audit(engine, req, audit_hook)
except EngineError as e:
base.error = f"engine_error: {e}"
return base
@@ -834,7 +843,9 @@ async def evaluate_session(
base.strengths = _coerce_str_list(payload.get("strengths"))
base.improvements = _coerce_str_list(payload.get("improvements"))[:3] # 최대 3
- base.alternative_utterances = _coerce_str_list(payload.get("alternative_utterances"))
+ base.alternative_utterances = _coerce_str_list(
+ payload.get("alternative_utterances")
+ )
rationale = payload.get("supervisor_rationale")
critique = payload.get("supervisor_critique")
base.supervisor_rationale = str(rationale).strip() if rationale else None
@@ -850,18 +861,6 @@ async def evaluate_session(
# ════════════════════════════════════════════════════════════════════════════
# 7. orchestrator EvalHook 어댑터 — 주입형 클로저(엔진 바인딩)
# ════════════════════════════════════════════════════════════════════════════
-async def _record_llm_audit(
- audit_hook: Optional["LlmAuditHook"],
- **payload: Any,
-) -> None:
- if audit_hook is None:
- return
- try:
- await audit_hook(payload)
- except Exception:
- return
-
-
def make_eval_hook(
engine: EngineClient,
*,
@@ -874,7 +873,9 @@ def make_eval_hook(
"""
async def _hook(ctx: "TurnContext", client_reply: str) -> Optional[dict[str, Any]]:
- ev = await evaluate_turn(ctx, client_reply, engine=engine, audit_hook=audit_hook)
+ ev = await evaluate_turn(
+ ctx, client_reply, engine=engine, audit_hook=audit_hook
+ )
d = ev.to_hook_dict()
return d if d else None
diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py
index ed3a214..40f66cf 100644
--- a/apps/api/app/services/guardrail.py
+++ b/apps/api/app/services/guardrail.py
@@ -370,9 +370,12 @@ def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> Cr
# 3. 출력 가드레일 (내담자 AI 응답 — 자살수단 차단, ideation 상한 R5)
# ════════════════════════════════════════════════════════════════════════════
# 자살/자해 '수단·방법' 정보 패턴 (절대 출력 금지). 정서 표현은 허용.
+# 1.1 확장(2026-07-15): DBT 자해 페르소나 대비 자해 도구·방법 어휘 보강.
+# 과차단 주의 — "선을 긋다"(경계 설정) 같은 정상 상담 표현과 겹치지 않는 조합만 추가.
_MEANS_TERMS = [
"약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약",
"손목을", "칼로", "뛰어내리", "수면제 ", "독", "방법은", "어떻게 죽",
+ "커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지",
]
_DISPLAY_PLACEHOLDER_LABELS = {
"NAME": "그 이름",
diff --git a/apps/api/app/services/live_coach.py b/apps/api/app/services/live_coach.py
index 7730968..48520ce 100644
--- a/apps/api/app/services/live_coach.py
+++ b/apps/api/app/services/live_coach.py
@@ -136,6 +136,10 @@ class LiveCoachInput(BaseModel):
client_reply: Optional[str] = None
recent_turns: list[dict[str, str]] = Field(default_factory=list)
evaluation: Optional[dict[str, Any]] = None
+ # 이번 회기 목표 단계(P1 준비 페이지 선택) — 코칭을 회기 목표에 정렬한다.
+ goal_stages: list[str] = Field(default_factory=list)
+ # 직전 코칭 요약(title/focus) — 같은 조언 반복을 막는다.
+ prior_coach: list[dict[str, str]] = Field(default_factory=list)
class LiveCoachGrounding(BaseModel):
@@ -529,7 +533,17 @@ def _fallback_suggestion(
focus: CoachFocus = "exploration"
title = "다음 탐색"
message = "내담자 표현을 한 번 반영한 뒤, 방금 말한 장면을 더 구체적으로 물어봐라."
- next_line = "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?"
+ # 단계별 기본 다음 발화 — 폴백에서도 회기 흐름에 맞는 제안을 낸다.
+ stage_next_lines = {
+ "라포": "오늘 이렇게 시간 내줘서 고마워요. 지금 마음이 어떤지 편한 만큼만 들려줄래요?",
+ "탐색": "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?",
+ "개입": "그 생각이 올라올 때 몸이나 행동은 어떻게 반응하는지 같이 한번 살펴볼까요?",
+ "정리": "오늘 나눈 이야기 중에 가장 마음에 남는 것 하나를 같이 정리해 볼까요?",
+ }
+ next_line = stage_next_lines.get(
+ str(item.stage),
+ "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?",
+ )
crisis = guardrail.classify_crisis(text)
if crisis.kind != guardrail.CrisisKind.NONE:
@@ -633,10 +647,21 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
"영역의 구체 행동으로 연결한다. DSM/지침 근거는 상담자 판단을 정렬하는 내부 참조이며, "
"학습자에게는 관찰 가능한 상담 행동과 다음 발화로만 번역한다."
)
+ goals = ", ".join(item.goal_stages) if item.goal_stages else "(미지정)"
+ prior = (
+ "\n".join(
+ f"- {entry.get('title', '')} (focus: {entry.get('focus', '')})"
+ for entry in item.prior_coach[-2:]
+ if entry.get("title")
+ )
+ or "(이번 회기 첫 코칭)"
+ )
user = (
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
f"[내담자] {item.persona_name} ({item.persona_code})\n"
- f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n\n"
+ f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n"
+ f"[이번 회기 목표 단계] {goals} — 코칭은 목표 단계 작업에 정렬하고, 목표를 이미 이뤘다면 심화를 제안한다.\n"
+ f"[직전 코칭]\n{prior}\n(같은 조언을 반복하지 말고 다음 단계를 제시한다)\n\n"
f"[최근 맥락]\n{recent}\n\n"
f"[이번 상담자 발화]\n{learner_masked}\n\n"
f"[이어진 내담자 응답]\n{client_masked or '(아직 없음)'}\n\n"
@@ -654,13 +679,14 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
async def _record_llm_audit(
audit_hook: Optional["LlmAuditHook"],
**payload: Any,
-) -> None:
+) -> bool:
if audit_hook is None:
- return
+ return True
try:
- await audit_hook(payload)
+ result = await audit_hook(payload)
+ return result is not False
except Exception:
- return
+ return False
async def generate_live_coaching(
@@ -691,7 +717,7 @@ async def generate_live_coaching(
)
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
- await _record_llm_audit(
+ audit_ok = await _record_llm_audit(
audit_hook,
session_id=item.session_id,
provider=resp.provider,
@@ -702,6 +728,12 @@ async def generate_live_coaching(
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
+ if not audit_ok:
+ return _fallback_suggestion(
+ item,
+ grounding=all_grounding,
+ reason="응답 검증 기록을 남기지 못했다",
+ )
payload = structured_payload_from_response(resp)
if payload is None:
return _fallback_suggestion(
diff --git a/apps/api/app/services/llm_audit.py b/apps/api/app/services/llm_audit.py
new file mode 100644
index 0000000..6af24db
--- /dev/null
+++ b/apps/api/app/services/llm_audit.py
@@ -0,0 +1,47 @@
+"""LLM 호출 계량과 감사 훅의 공통 실행 경로."""
+
+from __future__ import annotations
+
+import time
+from collections.abc import Awaitable, Callable
+from typing import Any
+
+from ..engine_client import EngineClient, GenerateRequest, GenerateResponse
+
+LlmAuditHook = Callable[[dict[str, Any]], Awaitable[None]]
+
+
+async def record_llm_audit(
+ audit_hook: LlmAuditHook | None,
+ **payload: Any,
+) -> None:
+ """감사 저장소 장애가 사용자 응답을 막지 않도록 훅 실패를 격리한다."""
+ if audit_hook is None:
+ return
+ try:
+ await audit_hook(payload)
+ except Exception:
+ return
+
+
+async def generate_with_audit(
+ engine: EngineClient,
+ request: GenerateRequest,
+ audit_hook: LlmAuditHook | None,
+) -> GenerateResponse:
+ """비스트리밍 LLM 호출의 지연·토큰·비용 기록을 한 계약으로 고정한다."""
+ started = time.perf_counter()
+ response = await engine.generate(request)
+ latency_ms = int((time.perf_counter() - started) * 1000)
+ await record_llm_audit(
+ audit_hook,
+ session_id=request.session_id,
+ provider=response.provider,
+ model=response.model,
+ tokens_in=response.tokens_in,
+ tokens_out=response.tokens_out,
+ cost_usd=response.cost_usd,
+ inference_geo=response.inference_geo,
+ latency_ms=latency_ms,
+ )
+ return response
diff --git a/apps/api/app/services/memory.py b/apps/api/app/services/memory.py
index 1d0f117..b2310d4 100644
--- a/apps/api/app/services/memory.py
+++ b/apps/api/app/services/memory.py
@@ -20,7 +20,7 @@ from __future__ import annotations
import re
from dataclasses import dataclass, field
-from typing import Any, Callable, Literal, Optional
+from typing import Any, Literal, Optional
from .state_machine import SessionState
from ..taxonomy import speaker_ko_label
diff --git a/apps/api/app/services/notifications.py b/apps/api/app/services/notifications.py
index 465bcc1..31507e3 100644
--- a/apps/api/app/services/notifications.py
+++ b/apps/api/app/services/notifications.py
@@ -15,10 +15,32 @@ from uuid import uuid4
from ..config import settings
from ..db import acquire, get_pool
+from ..runtime_schema import (
+ NOTIFICATION_SCHEMA_CONTRACT,
+ runtime_schema_bootstrap_required,
+ schema_contract_ready,
+)
logger = logging.getLogger(__name__)
-NotificationKind = Literal["account_pending_approval", "session_review_ready", "admin_test_email"]
+NotificationKind = Literal[
+ "account_pending_approval", "session_review_ready", "admin_test_email"
+]
+ApprovalRecipientScope = Literal["admin", "super_admin"]
+
+ACTIVE_NOTIFICATION_RECIPIENTS_SQL = """
+ SELECT DISTINCT
+ u.user_id,
+ u.email,
+ COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
+ u.role
+ FROM app.app_user u
+ LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
+ WHERE u.is_active
+ AND u.account_status = 'approved'
+ AND u.email IS NOT NULL
+ AND u.email <> ''
+"""
@dataclass(slots=True)
@@ -38,9 +60,14 @@ class RenderedEmail:
async def ensure_notification_tables() -> None:
- """Create notification queue tables when the DB role allows DDL."""
+ """Verify notification tables, with DDL repair restricted to local development."""
get_pool()
async with acquire(role="admin") as conn:
+ ready = await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT)
+ if not runtime_schema_bootstrap_required(
+ NOTIFICATION_SCHEMA_CONTRACT, ready=ready
+ ):
+ return
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.notification_event (
@@ -120,6 +147,10 @@ async def ensure_notification_tables() -> None:
WITH CHECK (app.current_role_name() = 'admin');
"""
)
+ if not await schema_contract_ready(conn, NOTIFICATION_SCHEMA_CONTRACT):
+ raise RuntimeError(
+ "notification development schema bootstrap did not satisfy readiness"
+ )
def schedule_delivery_flush() -> None:
@@ -127,7 +158,9 @@ def schedule_delivery_flush() -> None:
if settings.notification_email_provider == "disabled":
return
try:
- asyncio.get_running_loop().create_task(process_queued_email_notifications(limit=10))
+ asyncio.get_running_loop().create_task(
+ process_queued_email_notifications(limit=10)
+ )
except RuntimeError:
return
@@ -321,57 +354,39 @@ async def _enqueue_event(
async def _admin_approval_recipients() -> list[NotificationRecipient]:
- super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
+ return await _approval_recipients(scope="admin")
+
+
+async def _super_admin_recipients() -> list[NotificationRecipient]:
+ return await _approval_recipients(scope="super_admin")
+
+
+async def _approval_recipients(
+ *, scope: ApprovalRecipientScope
+) -> list[NotificationRecipient]:
+ """가입 승인 알림 수신 정책을 한 쿼리에서 소유한다."""
+ super_admin_emails = sorted(
+ {_normalize_email(value) for value in settings.auth_super_admin_emails}
+ )
+ super_admin_only = scope == "super_admin"
+ if super_admin_only and not super_admin_emails:
+ return []
async with acquire(role="admin") as conn:
rows = await conn.fetch(
- """
- SELECT DISTINCT
- u.user_id,
- u.email,
- COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
- u.role
- FROM app.app_user u
- LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
- WHERE u.is_active
- AND u.account_status = 'approved'
- AND u.email IS NOT NULL
- AND u.email <> ''
+ ACTIVE_NOTIFICATION_RECIPIENTS_SQL
+ + """
AND (
- u.role = 'admin'
- OR u.admin_access
- OR lower(u.email) = ANY($1::text[])
+ lower(u.email) = ANY($1::text[])
+ OR (
+ NOT $2::boolean
+ AND (u.role = 'admin' OR u.admin_access)
+ )
)
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
ORDER BY u.email
""",
super_admin_emails,
- )
- return [_recipient_from_row(row) for row in rows]
-
-
-async def _super_admin_recipients() -> list[NotificationRecipient]:
- super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
- if not super_admin_emails:
- return []
- async with acquire(role="admin") as conn:
- rows = await conn.fetch(
- """
- SELECT DISTINCT
- u.user_id,
- u.email,
- COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
- u.role
- FROM app.app_user u
- LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
- WHERE u.is_active
- AND u.account_status = 'approved'
- AND u.email IS NOT NULL
- AND u.email <> ''
- AND lower(u.email) = ANY($1::text[])
- AND COALESCE((p.notifications->>'account_approval')::boolean, true)
- ORDER BY u.email
- """,
- super_admin_emails,
+ super_admin_only,
)
return [_recipient_from_row(row) for row in rows]
@@ -379,7 +394,9 @@ async def _super_admin_recipients() -> list[NotificationRecipient]:
async def _session_review_payload_and_recipients(
session_id: str,
) -> tuple[dict[str, Any] | None, list[NotificationRecipient]]:
- super_admin_emails = sorted({_normalize_email(value) for value in settings.auth_super_admin_emails})
+ super_admin_emails = sorted(
+ {_normalize_email(value) for value in settings.auth_super_admin_emails}
+ )
async with acquire(role="admin") as conn:
session = await conn.fetchrow(
"""
@@ -407,18 +424,8 @@ async def _session_review_payload_and_recipients(
return None, []
learner_cohort = str(session["learner_cohort"] or "")
rows = await conn.fetch(
- """
- SELECT DISTINCT
- u.user_id,
- u.email,
- COALESCE(NULLIF(u.display_name, ''), u.email) AS display_name,
- u.role
- FROM app.app_user u
- LEFT JOIN app.user_preferences p ON p.user_id = u.user_id
- WHERE u.is_active
- AND u.account_status = 'approved'
- AND u.email IS NOT NULL
- AND u.email <> ''
+ ACTIVE_NOTIFICATION_RECIPIENTS_SQL
+ + """
AND (
u.role = 'admin'
OR lower(u.email) = ANY($1::text[])
@@ -457,7 +464,9 @@ def _send_rendered_email(
if settings.notification_email_provider == "disabled":
raise NotificationSkipped("email provider is disabled")
if settings.notification_email_provider != "smtp":
- raise NotificationSkipped(f"unsupported provider: {settings.notification_email_provider}")
+ raise NotificationSkipped(
+ f"unsupported provider: {settings.notification_email_provider}"
+ )
if not settings.smtp_host.strip() or not settings.smtp_from_email.strip():
raise NotificationSkipped("SMTP host/from email is not configured")
@@ -472,7 +481,9 @@ def _send_rendered_email(
if settings.smtp_ssl:
context = ssl.create_default_context()
- with smtplib.SMTP_SSL(settings.smtp_host, settings.smtp_port, context=context, timeout=15) as smtp:
+ with smtplib.SMTP_SSL(
+ settings.smtp_host, settings.smtp_port, context=context, timeout=15
+ ) as smtp:
_smtp_login_if_needed(smtp)
smtp.send_message(msg)
else:
@@ -553,7 +564,9 @@ async def _mark_delivery_failed(delivery_id: str, error: str) -> None:
def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
- display_name = str(payload.get("display_name") or payload.get("email") or "신규 사용자")
+ display_name = str(
+ payload.get("display_name") or payload.get("email") or "신규 사용자"
+ )
email = str(payload.get("email") or "")
role = _role_label(str(payload.get("role") or "learner"))
approval_url = str(payload.get("approval_url") or _frontend_url("/admin/users"))
@@ -563,11 +576,15 @@ def _render_account_pending_approval(payload: dict[str, Any]) -> RenderedEmail:
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 뒤 승인 상태를 결정해 주세요.
- {_info_box([
- ("사용자", display_name),
- ("이메일", email),
- ("요청 역할", role),
- ])}
+ {
+ _info_box(
+ [
+ ("사용자", display_name),
+ ("이메일", email),
+ ("요청 역할", role),
+ ]
+ )
+ }
{_button("가입 승인 확인하기", approval_url)}
계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 볼 수 있습니다.
@@ -602,12 +619,16 @@ def _render_session_review_ready(payload: dict[str, Any]) -> RenderedEmail:
종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 뒤 검토 상태를 남겨 주세요.
- {_info_box([
- ("학습자", learner_label),
- ("내담자", persona_name),
- ("회기", f"{session_no}회기" if session_no else "종료 회기"),
- ("종료 시각", ended_at or "기록됨"),
- ])}
+ {
+ _info_box(
+ [
+ ("학습자", learner_label),
+ ("내담자", persona_name),
+ ("회기", f"{session_no}회기" if session_no else "종료 회기"),
+ ("종료 시각", ended_at or "기록됨"),
+ ]
+ )
+ }
{_button("회기 검토하기", review_url)}
민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 후 Vignette에서 확인해 주세요.
@@ -641,10 +662,14 @@ def _render_admin_test_email(payload: dict[str, Any]) -> RenderedEmail:
관리자 메일 알림이 정상적으로 연결되었습니다. 이 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
- {_info_box([
- ("요청자", requested_by),
- ("용도", "운영 메일 발송 테스트"),
- ])}
+ {
+ _info_box(
+ [
+ ("요청자", requested_by),
+ ("용도", "운영 메일 발송 테스트"),
+ ]
+ )
+ }
{_button("알림 상태 확인하기", notifications_url)}
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.
diff --git a/apps/api/app/services/orchestrator.py b/apps/api/app/services/orchestrator.py
index 03c2bf7..c755f66 100644
--- a/apps/api/app/services/orchestrator.py
+++ b/apps/api/app/services/orchestrator.py
@@ -41,13 +41,13 @@ from ..contracts.engine_gateway import (
StreamTokenEvent,
)
from . import guardrail, persona, state_machine
+from .llm_audit import LlmAuditHook, generate_with_audit, record_llm_audit
from .persona import PersonaCard, PersonaStateContext, TurnMemory
-from .state_machine import SessionState, Stage
+from .state_machine import SessionState
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
-LlmAuditHook = Callable[[dict[str, Any]], Awaitable[None]]
def turn_evaluation_error_payload(ctx: "TurnContext", error: BaseException | str) -> dict[str, Any]:
@@ -253,20 +253,7 @@ async def run_turn_generate(
reply = ""
safety_flagged = ctx.crisis is not None and ctx.crisis.escalate
for attempt in range(2):
- started = time.perf_counter()
- resp = await engine.generate(req)
- latency_ms = int((time.perf_counter() - started) * 1000)
- await _record_llm_audit(
- audit_hook,
- session_id=ctx.session_id,
- provider=resp.provider,
- model=resp.model,
- tokens_in=resp.tokens_in,
- tokens_out=resp.tokens_out,
- cost_usd=resp.cost_usd,
- inference_geo=resp.inference_geo,
- latency_ms=latency_ms,
- )
+ resp = await generate_with_audit(engine, req, audit_hook)
# 5) 출력 가드레일 — 수단 차단 + persona 품질 재생성
guard = guardrail.sanitize_client_reply(
@@ -451,7 +438,7 @@ async def run_turn_stream(
yield StreamEvent("token", {"text": guard.text})
latency_ms = int((time.perf_counter() - started) * 1000)
- await _record_llm_audit(
+ await record_llm_audit(
audit_hook,
session_id=ctx.session_id,
provider=str(stream_meta.get("provider") or engine.engine_mode),
@@ -493,18 +480,6 @@ async def run_turn_stream(
yield StreamEvent("error", {"detail": str(e)})
-async def _record_llm_audit(
- audit_hook: Optional[LlmAuditHook],
- **payload: Any,
-) -> None:
- if audit_hook is None:
- return
- try:
- await audit_hook(payload)
- except Exception:
- return
-
-
def _optional_str(value: Any) -> Optional[str]:
if value is None:
return None
diff --git a/apps/api/app/services/persona.py b/apps/api/app/services/persona.py
index d6fdc74..910ab21 100644
--- a/apps/api/app/services/persona.py
+++ b/apps/api/app/services/persona.py
@@ -21,10 +21,13 @@ P1 = 0615 청소년 '서연' 사례의 합성 변형(원문 미적재, F-05).
from __future__ import annotations
from dataclasses import dataclass, field
-from typing import Any, Optional
+from typing import TYPE_CHECKING, Any, Optional
from ..engine_client import EngineMessage
+if TYPE_CHECKING:
+ from .state_machine import OpennessParams
+
# ════════════════════════════════════════════════════════════════════════════
# 페르소나 카드 (app.persona_card 컬럼 구조의 in-proc 표현)
diff --git a/apps/api/app/services/phase3_kpi_export.py b/apps/api/app/services/phase3_kpi_export.py
index c152ebc..31ecb74 100644
--- a/apps/api/app/services/phase3_kpi_export.py
+++ b/apps/api/app/services/phase3_kpi_export.py
@@ -17,7 +17,7 @@ from typing import Any, Iterable, Mapping, Sequence
from uuid import UUID
from .phase3_kpi_contract import (
- KPI_REPORT_PATH,
+ KPI_REPORT_PATH as KPI_REPORT_PATH,
PHASE3_KPI_METRICS,
PREPOST_CSV_PATH,
PREPOST_MEASURE_NAMES,
diff --git a/apps/api/app/services/session_metrics.py b/apps/api/app/services/session_metrics.py
index b205ddd..d457804 100644
--- a/apps/api/app/services/session_metrics.py
+++ b/apps/api/app/services/session_metrics.py
@@ -7,14 +7,7 @@ from datetime import datetime
from typing import Any, Callable
from ..store import InProcSession
-
-
-_APPROPRIATENESS_SCORE = {
- "neg": 0.0,
- "warn": 0.25,
- "neutral": 0.5,
- "pos": 1.0,
-}
+from .evaluation_contract import GROWTH_APPROPRIATENESS_SCORE_01
@dataclass(frozen=True)
@@ -80,7 +73,7 @@ def turn_score(ev: dict[str, Any]) -> float | None:
if str(ev.get("error") or "").strip():
return None
raw = str(ev.get("appropriateness") or "").strip().lower()
- return _APPROPRIATENESS_SCORE.get(raw)
+ return GROWTH_APPROPRIATENESS_SCORE_01.get(raw)
def turn_rapport(ev: dict[str, Any]) -> float | None:
@@ -179,7 +172,9 @@ def build_learner_growth(
ordered = sorted(learner_sessions, key=lambda sess: sess.created_at)
points = [session_growth_point(sess) for sess in ordered]
scored = [point for point in points if point.score is not None]
- rapport_values = [point.rapport for point in points if point.rapport is not None]
+ rapport_values = [
+ point.rapport for point in points if point.rapport is not None
+ ]
technique_counts: dict[str, int] = {}
for sess in ordered:
for turn in sess.turns:
@@ -223,8 +218,12 @@ def build_learner_growth(
first_score=first_score,
latest_score=latest_score,
score_delta=score_delta,
- avg_score=avg([point.score for point in scored if point.score is not None]),
- avg_rapport=avg([value for value in rapport_values if value is not None]),
+ avg_score=avg(
+ [point.score for point in scored if point.score is not None]
+ ),
+ avg_rapport=avg(
+ [value for value in rapport_values if value is not None]
+ ),
trend=trend,
top_techniques=top_techniques,
points=points if point_limit is None else points[-point_limit:],
@@ -234,7 +233,9 @@ def build_learner_growth(
return sorted_result if limit is None else sorted_result[:limit]
-def recent_feedback_notes(sessions: list[InProcSession], *, limit: int = 5) -> list[dict[str, object]]:
+def recent_feedback_notes(
+ sessions: list[InProcSession], *, limit: int = 5
+) -> list[dict[str, object]]:
notes: list[dict[str, object]] = []
for sess in sorted(sessions, key=session_activity_time, reverse=True):
for turn in reversed(sess.turns):
@@ -254,7 +255,9 @@ def recent_feedback_notes(sessions: list[InProcSession], *, limit: int = 5) -> l
"session_no": sess.session_no,
"stage": turn.stage,
"turn_seq": turn.turn_seq,
- "created_at": iso_datetime(turn.created_at) or iso_datetime(session_activity_time(sess)) or "",
+ "created_at": iso_datetime(turn.created_at)
+ or iso_datetime(session_activity_time(sess))
+ or "",
"score": turn_score(ev),
"rapport": turn_rapport(ev),
"note": note,
diff --git a/apps/api/app/services/tabular_ingest.py b/apps/api/app/services/tabular_ingest.py
new file mode 100644
index 0000000..04aed73
--- /dev/null
+++ b/apps/api/app/services/tabular_ingest.py
@@ -0,0 +1,138 @@
+"""자유 양식 표 파일(엑셀/CSV) → 텍스트 변환 (P4, 2026-07-13 한신대 회의).
+
+연구팀이 교수자 페이지에 올리는 자유 양식 엑셀(파란 라벨 방식 포함)을
+페르소나 저작 KB에 넣을 수 있는 평문으로 결정론 변환한다.
+
+원칙:
+- 업로드 원본 바이트는 여기서 파싱만 하고 어디에도 저장하지 않는다(원본 파기 원칙).
+ 파생 텍스트만 기존 `/personas/sources` 마스킹·hash-only 증거 경로로 넘어간다.
+- 양식 변형에 견디도록 셀 색/서식에 의존하지 않는다 — 비어있지 않은 셀만 행 단위로 평탄화한다.
+- openpyxl 은 선택 의존성이다(presidio 패턴). 없으면 명확한 한국어 오류로 안내한다.
+"""
+
+from __future__ import annotations
+
+import csv
+import io
+
+# 변환 상한 — 파일럿 추고록 기준 여유값. LLM/KB 입력 상한(120k)과 정합.
+MAX_TEXT_CHARS = 120_000
+MAX_CELLS = 40_000
+MAX_UPLOAD_BYTES = 8 * 1024 * 1024 # 8MB
+
+
+class TabularIngestError(ValueError):
+ """사용자에게 그대로 보여줄 수 있는 한국어 사유를 담는다."""
+
+
+def _try_load_openpyxl():
+ try:
+ import openpyxl # noqa: PLC0415 — 선택 의존성 지연 로드
+
+ return openpyxl
+ except ImportError as exc: # pragma: no cover - 설치 환경에선 도달하지 않음
+ raise TabularIngestError(
+ "엑셀 변환 모듈(openpyxl)이 설치되어 있지 않습니다. 관리자에게 API 의존성 설치를 요청하세요."
+ ) from exc
+
+
+def _cell_text(value: object) -> str:
+ if value is None:
+ return ""
+ if isinstance(value, float) and value.is_integer():
+ return str(int(value))
+ text = str(value).strip()
+ return " ".join(text.split())
+
+
+def _rows_to_lines(rows: list[list[str]]) -> list[str]:
+ lines: list[str] = []
+ for cells in rows:
+ filled = [cell for cell in cells if cell]
+ if not filled:
+ continue
+ if len(filled) == 2:
+ # 자유 양식에서 가장 흔한 "라벨 | 값" 행 — 읽기 좋은 쌍으로 변환.
+ lines.append(f"{filled[0]}: {filled[1]}")
+ else:
+ lines.append(" | ".join(filled))
+ return lines
+
+
+def _extract_xlsx(data: bytes) -> str:
+ openpyxl = _try_load_openpyxl()
+ try:
+ workbook = openpyxl.load_workbook(
+ io.BytesIO(data), read_only=True, data_only=True
+ )
+ except Exception as exc:
+ raise TabularIngestError(
+ "엑셀 파일을 열지 못했습니다. 손상되지 않은 .xlsx 파일인지 확인해 주세요."
+ ) from exc
+ try:
+ sections: list[str] = []
+ cell_budget = MAX_CELLS
+ for sheet in workbook.worksheets:
+ rows: list[list[str]] = []
+ for row in sheet.iter_rows(values_only=True):
+ if cell_budget <= 0:
+ break
+ cell_budget -= len(row)
+ rows.append([_cell_text(value) for value in row])
+ lines = _rows_to_lines(rows)
+ if lines:
+ sections.append(f"## 시트: {sheet.title}\n" + "\n".join(lines))
+ if cell_budget <= 0:
+ sections.append("(셀 수 상한에 도달해 이후 내용은 생략했습니다)")
+ break
+ return "\n\n".join(sections)
+ finally:
+ workbook.close()
+
+
+def _extract_csv(data: bytes) -> str:
+ text: str | None = None
+ for encoding in ("utf-8-sig", "cp949", "utf-8"):
+ try:
+ text = data.decode(encoding)
+ break
+ except UnicodeDecodeError:
+ continue
+ if text is None:
+ raise TabularIngestError(
+ "CSV 인코딩을 해석하지 못했습니다. UTF-8 또는 엑셀(xlsx)로 저장해 다시 올려 주세요."
+ )
+ rows = [[_cell_text(cell) for cell in row] for row in csv.reader(io.StringIO(text))]
+ return "\n".join(_rows_to_lines(rows[: MAX_CELLS // 8]))
+
+
+def extract_tabular_text(*, filename: str, data: bytes) -> str:
+ """업로드 파일을 KB 등록용 평문으로 변환한다. 실패 사유는 TabularIngestError."""
+ if not data:
+ raise TabularIngestError("업로드된 파일이 비어 있습니다.")
+ if len(data) > MAX_UPLOAD_BYTES:
+ raise TabularIngestError("파일이 8MB를 넘습니다. 시트를 나눠 다시 올려 주세요.")
+
+ lowered = (filename or "").lower()
+ if lowered.endswith(".xlsx") or lowered.endswith(".xlsm"):
+ text = _extract_xlsx(data)
+ elif lowered.endswith(".xls"):
+ raise TabularIngestError(
+ "구형 엑셀(.xls)은 지원하지 않습니다. 엑셀에서 '다른 이름으로 저장 → .xlsx'로 변환해 올려 주세요."
+ )
+ elif lowered.endswith(".csv"):
+ text = _extract_csv(data)
+ else:
+ raise TabularIngestError(
+ "지원하지 않는 파일 형식입니다. 엑셀(.xlsx) 또는 CSV 파일을 올려 주세요."
+ )
+
+ text = text.strip()
+ if len(text) < 20:
+ raise TabularIngestError(
+ "표에서 읽을 수 있는 텍스트가 거의 없습니다. 내용이 있는 시트인지 확인해 주세요."
+ )
+ return text[:MAX_TEXT_CHARS]
+
+
+__all__ = ["TabularIngestError", "extract_tabular_text", "MAX_UPLOAD_BYTES"]
diff --git a/apps/api/app/session_persistence.py b/apps/api/app/session_persistence.py
index 2f290de..9a83078 100644
--- a/apps/api/app/session_persistence.py
+++ b/apps/api/app/session_persistence.py
@@ -13,9 +13,20 @@ from typing import Any, Iterable, Protocol
from .db import acquire, get_pool
from .deps import Principal
from .config import settings
-from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id
+from .persona_repository import (
+ SEED_VERSION,
+ card_from_row,
+ seed_fallback_persona,
+ seed_persona_id,
+)
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
+from .runtime_schema import (
+ REVIEW_SCHEMA_CONTRACT,
+ runtime_schema_bootstrap_required,
+ schema_contract_ready,
+)
from .services import guardrail, memory, state_machine
+from .services.evaluation_contract import PERSISTED_APPROPRIATENESS_SCORE_5PT
from .services.persona import PersonaCard
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
@@ -33,16 +44,60 @@ _WORKSHEET_REVIEW_STATUS_VALUES = {
"changes_requested",
"rejected",
}
-_APPROPRIATENESS_SCORE = {
- "warn": 1.0,
- "neutral": 3.0,
- "pos": 5.0,
-}
LIVE_COACH_INITIAL_CREDITS = 3
LIVE_COACH_MAX_CREDITS = 3
LIVE_COACH_USE_REASON = "AI 코칭 힌트 사용"
LIVE_COACH_RECHARGE_REASON = "좋은 발화로 내담자 변화 신호 확인"
+SESSION_PERSONA_SELECT_COLUMNS_SQL = """
+ s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
+ s.session_no, s.theory_mode, s.started_at, s.ended_at,
+ s.prev_rapport_credit, s.session_goals,
+ COALESCE(
+ NULLIF(learner.display_name, ''),
+ NULLIF(learner.nickname, ''),
+ NULLIF(learner.email, ''),
+ s.learner_id::text
+ ) AS learner_label,
+ pc.persona_id AS card_persona_id,
+ pc.code AS card_code,
+ pc.version AS card_version,
+ pc.status AS card_status,
+ pc.display_name AS card_display_name,
+ pc.difficulty AS card_difficulty,
+ pc.theory_target AS card_theory_target,
+ pc.demographics AS card_demographics,
+ pc.presenting AS card_presenting,
+ pc.history AS card_history,
+ pc.big5 AS card_big5,
+ pc.resistance AS card_resistance,
+ pc.speech_style AS card_speech_style,
+ pc.affect_baseline AS card_affect_baseline,
+ pc.ccd AS card_ccd,
+ pc.dsm5_dimensional AS card_dsm5_dimensional,
+ pc.triggers AS card_triggers,
+ pc.source_provenance AS card_source_provenance,
+ pc.is_synthetic AS card_is_synthetic
+"""
+
+SESSION_PERSONA_JOINS_SQL = """
+ LEFT JOIN app.persona_card pc
+ ON pc.persona_id = s.persona_id
+ AND pc.version = s.persona_version
+ LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
+"""
+
+SESSION_STATE_SELECT_COLUMNS_SQL = """
+ session_id, stage, turn_seq, effective_openness, rapport_credit, resistance,
+ ideation_stage, turns_in_stage, affect_state
+"""
+
+SESSION_TURN_SELECT_COLUMNS_SQL = """
+ session_id, id, seq, speaker, stage, text, text_masked, created_at,
+ llm_provider, model, tokens_in, tokens_out, cost_usd,
+ audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
+"""
+
class LiveCoachCreditExhausted(RuntimeError):
"""Raised when a learner tries to use live coaching without credits."""
@@ -227,6 +282,21 @@ def _json_object_payload(value: Any) -> dict[str, Any]:
return {}
+def _json_list_payload(value: Any) -> list[Any]:
+ if isinstance(value, list):
+ return list(value)
+ if not isinstance(value, str):
+ return []
+ text = value.strip()
+ if not text:
+ return []
+ try:
+ parsed = json.loads(text)
+ except json.JSONDecodeError:
+ return []
+ return parsed if isinstance(parsed, list) else []
+
+
def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
text = (value or "").strip()
if not text:
@@ -261,7 +331,10 @@ def _live_coach_remaining(value: int | float | None) -> int:
def _live_coach_quota_payload(remaining: int | float | None) -> dict[str, int]:
- return {"remaining": _live_coach_remaining(remaining), "max": LIVE_COACH_MAX_CREDITS}
+ return {
+ "remaining": _live_coach_remaining(remaining),
+ "max": LIVE_COACH_MAX_CREDITS,
+ }
def _live_coach_delta_from_record(record: dict[str, Any]) -> int:
@@ -309,9 +382,14 @@ def _live_coach_credit_event_from_row(row) -> dict[str, Any]:
"event_type": event_type,
"delta": int(delta),
"balance": _live_coach_remaining(balance),
- "reason": str(_row_value(row, "reason") or (
- LIVE_COACH_RECHARGE_REASON if event_type == "recharge" else LIVE_COACH_USE_REASON
- )),
+ "reason": str(
+ _row_value(row, "reason")
+ or (
+ LIVE_COACH_RECHARGE_REASON
+ if event_type == "recharge"
+ else LIVE_COACH_USE_REASON
+ )
+ ),
"created_at": _iso_dt(row["created_at"]),
}
@@ -329,9 +407,14 @@ def _live_coach_credit_event_from_record(record: dict[str, Any]) -> dict[str, An
"event_type": event_type,
"delta": delta,
"balance": _live_coach_remaining(record.get("credit_balance")),
- "reason": str(record.get("reason") or (
- LIVE_COACH_RECHARGE_REASON if event_type == "recharge" else LIVE_COACH_USE_REASON
- )),
+ "reason": str(
+ record.get("reason")
+ or (
+ LIVE_COACH_RECHARGE_REASON
+ if event_type == "recharge"
+ else LIVE_COACH_USE_REASON
+ )
+ ),
"created_at": str(record.get("created_at") or ""),
}
@@ -348,7 +431,9 @@ def _live_coach_cache_record(
) -> dict[str, Any]:
now = datetime.now(timezone.utc)
balance = _live_coach_remaining(
- credit_balance if credit_balance is not None else _live_coach_cached_remaining(session_id) - 1
+ credit_balance
+ if credit_balance is not None
+ else _live_coach_cached_remaining(session_id) - 1
)
return {
"event_id": str(uuid.uuid4()),
@@ -407,8 +492,7 @@ def _card_from_joined_session_row(row) -> PersonaCard | None:
if _row_value(row, "card_persona_id") is None:
return None
card_row = {
- key.removeprefix("card_"): _row_value(row, key)
- for key in _JOINED_CARD_COLUMNS
+ key.removeprefix("card_"): _row_value(row, key) for key in _JOINED_CARD_COLUMNS
}
return card_from_row(card_row)
@@ -471,7 +555,9 @@ def _dict_items(value: Any) -> list[dict[str, Any]]:
return [item for item in value if isinstance(item, dict)]
-def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
+def _evaluation_feedback_rows(
+ evaluation: dict[str, Any] | None,
+) -> list[dict[str, Any]]:
"""Normalize scalar/rationale turn-evaluation fields into feedback_scores rows."""
if not isinstance(evaluation, dict):
return []
@@ -499,11 +585,11 @@ def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[st
)
appropriateness = _clean_text(evaluation.get("appropriateness")) or "neutral"
- if appropriateness not in _APPROPRIATENESS_SCORE:
+ if appropriateness not in PERSISTED_APPROPRIATENESS_SCORE_5PT:
appropriateness = "neutral"
add(
"appropriateness",
- score=_APPROPRIATENESS_SCORE[appropriateness],
+ score=PERSISTED_APPROPRIATENESS_SCORE_5PT[appropriateness],
rationale=_clean_masked_text(evaluation.get("appropriateness_note")),
)
@@ -534,7 +620,9 @@ def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[st
return rows
-def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
+def _evaluation_technique_rows(
+ evaluation: dict[str, Any] | None,
+) -> list[dict[str, str]]:
if not isinstance(evaluation, dict):
return []
rows: list[dict[str, str]] = []
@@ -552,7 +640,9 @@ def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[s
return rows
-def _evaluation_client_state_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
+def _evaluation_client_state_rows(
+ evaluation: dict[str, Any] | None,
+) -> list[dict[str, str]]:
if not isinstance(evaluation, dict):
return []
rows: list[dict[str, str]] = []
@@ -575,11 +665,21 @@ def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str
deviation = evaluation.get("intent_deviation")
if not isinstance(deviation, dict):
return []
- note = _clean_masked_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분"
- return [{"kind": "critique", "text": note, "intent_deviation": _mask_json_text_values(deviation)}]
+ note = (
+ _clean_masked_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분"
+ )
+ return [
+ {
+ "kind": "critique",
+ "text": note,
+ "intent_deviation": _mask_json_text_values(deviation),
+ }
+ ]
-def _evaluation_alternative_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str | None]]:
+def _evaluation_alternative_rows(
+ evaluation: dict[str, Any] | None,
+) -> list[dict[str, str | None]]:
if not isinstance(evaluation, dict):
return []
alternatives = evaluation.get("alternative_utterances")
@@ -747,11 +847,15 @@ async def record_llm_call_audit(payload: dict[str, Any]) -> bool:
The audit table intentionally stores no prompt or completion text. A DB outage
must not block the counseling loop, so failures are reported as False.
+
+ dev degraded(무DB) 기동에서는 audit 인프라 자체가 없는 것이 정상 폴백이므로,
+ 호출부가 이를 "기록 실패"로 보고 정상 LLM 응답을 강등하지 않도록 True 를 반환한다.
+ durable 환경에서 풀이 없거나 INSERT 가 실패한 경우에만 False 다.
"""
try:
get_pool()
except Exception:
- return False
+ return runtime_fallback_allowed()
try:
async with acquire(ai_context=True, ai_view="evaluator") as conn:
@@ -802,14 +906,18 @@ def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
created_at = _ts(row["created_at"]) or time.time()
- text_masked = _clean_text(row["text_masked"]) or _clean_masked_text(row["text"]) or ""
+ text_masked = (
+ _clean_text(row["text_masked"]) or _clean_masked_text(row["text"]) or ""
+ )
return TurnRecord(
turn_seq=int(row["seq"]),
speaker=row["speaker"],
stage=row["stage"],
text=text_masked,
text_masked=text_masked,
- turn_id=str(_row_value(row, "id")) if _row_value(row, "id") is not None else None,
+ turn_id=str(_row_value(row, "id"))
+ if _row_value(row, "id") is not None
+ else None,
created_at=created_at,
llm_provider=_row_value(row, "llm_provider"),
model=_row_value(row, "model"),
@@ -826,7 +934,9 @@ def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
)
-async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None:
+async def _persist_turn_evaluation(
+ conn: Any, turn_id: str, evaluation: dict[str, Any] | None
+) -> None:
if not isinstance(evaluation, dict):
return
await conn.execute("SELECT set_config('app.ai_context', '1', true)")
@@ -916,7 +1026,9 @@ async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str
row["intent_deviation"],
)
- await conn.execute("DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id)
+ await conn.execute(
+ "DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id
+ )
for row in _evaluation_alternative_rows(evaluation):
await conn.execute(
"""
@@ -931,19 +1043,33 @@ async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str
)
-async def _replace_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None:
+async def _replace_turn_evaluation(
+ conn: Any, turn_id: str, evaluation: dict[str, Any] | None
+) -> None:
await conn.execute("SELECT set_config('app.ai_context', '1', true)")
await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', true)")
await conn.execute("SELECT set_config('app.current_sens_max', '2', true)")
- await conn.execute("DELETE FROM app.feedback_scores WHERE turn_id = $1::uuid", turn_id)
- await conn.execute("DELETE FROM app.turn_technique WHERE turn_id = $1::uuid", turn_id)
- await conn.execute("DELETE FROM app.turn_client_state WHERE turn_id = $1::uuid", turn_id)
- await conn.execute("DELETE FROM app.supervisor_comment WHERE turn_id = $1::uuid", turn_id)
- await conn.execute("DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id)
+ await conn.execute(
+ "DELETE FROM app.feedback_scores WHERE turn_id = $1::uuid", turn_id
+ )
+ await conn.execute(
+ "DELETE FROM app.turn_technique WHERE turn_id = $1::uuid", turn_id
+ )
+ await conn.execute(
+ "DELETE FROM app.turn_client_state WHERE turn_id = $1::uuid", turn_id
+ )
+ await conn.execute(
+ "DELETE FROM app.supervisor_comment WHERE turn_id = $1::uuid", turn_id
+ )
+ await conn.execute(
+ "DELETE FROM app.alternative_utterance WHERE turn_id = $1::uuid", turn_id
+ )
await _persist_turn_evaluation(conn, turn_id, _mask_json_text_values(evaluation))
-async def replace_turn_evaluation(*, turn_id: str, evaluation: dict[str, Any] | None) -> bool:
+async def replace_turn_evaluation(
+ *, turn_id: str, evaluation: dict[str, Any] | None
+) -> bool:
if not turn_id or not isinstance(evaluation, dict):
return False
try:
@@ -1051,10 +1177,15 @@ async def _hydrate_sessions_turn_evaluations(sessions: list[InProcSession]) -> N
async def ensure_review_tables() -> None:
- """Create runtime review/evaluation storage when the DB role allows it."""
+ """Verify the review schema, with DDL repair restricted to local development."""
try:
get_pool()
async with acquire(role="admin") as conn:
+ ready = await schema_contract_ready(conn, REVIEW_SCHEMA_CONTRACT)
+ if not runtime_schema_bootstrap_required(
+ REVIEW_SCHEMA_CONTRACT, ready=ready
+ ):
+ return
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_evaluation (
@@ -1521,7 +1652,13 @@ async def ensure_review_tables() -> None:
)
"""
)
+ if not await schema_contract_ready(conn, REVIEW_SCHEMA_CONTRACT):
+ raise RuntimeError(
+ "review/evaluation development schema bootstrap did not satisfy readiness"
+ )
except Exception:
+ if settings.environment != "dev":
+ raise
return
@@ -1584,7 +1721,11 @@ async def load_session_evaluation(
session_id,
)
if row is None:
- cached = _EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
+ cached = (
+ _EVALUATION_CACHE.get(session_id)
+ if runtime_fallback_allowed()
+ else None
+ )
return cached, cached is None
return {
"status": row["status"],
@@ -1649,6 +1790,39 @@ async def list_session_evaluations(
}, False
+async def _fetch_session_runtime_rows(
+ conn: Any,
+ session_ids: list[str],
+) -> tuple[dict[str, Any], dict[str, list[Any]]]:
+ """세션 상태와 턴을 두 번의 배치 조회로 적재한다."""
+ if not session_ids:
+ return {}, {}
+ state_rows = await conn.fetch(
+ f"""
+ SELECT {SESSION_STATE_SELECT_COLUMNS_SQL}
+ FROM app.session_state
+ WHERE session_id = ANY($1::uuid[])
+ """,
+ session_ids,
+ )
+ states_by_id = {
+ str(state_row["session_id"]): state_row for state_row in state_rows
+ }
+ turn_rows = await conn.fetch(
+ f"""
+ SELECT {SESSION_TURN_SELECT_COLUMNS_SQL}
+ FROM app.turns
+ WHERE session_id = ANY($1::uuid[])
+ ORDER BY session_id, seq
+ """,
+ session_ids,
+ )
+ turns_by_id: dict[str, list[Any]] = {}
+ for turn_row in turn_rows:
+ turns_by_id.setdefault(str(turn_row["session_id"]), []).append(turn_row)
+ return states_by_id, turns_by_id
+
+
async def list_sessions_missing_session_evaluation(
*,
older_than_seconds: float,
@@ -1661,42 +1835,11 @@ async def list_sessions_missing_session_evaluation(
stale_seconds = max(float(older_than_seconds), 0.0)
async with acquire(ai_context=True, ai_view="evaluator") as conn:
rows = await conn.fetch(
- """
- SELECT
- s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
- s.session_no, s.theory_mode, s.started_at, s.ended_at,
- s.prev_rapport_credit,
- COALESCE(
- NULLIF(learner.display_name, ''),
- NULLIF(learner.nickname, ''),
- NULLIF(learner.email, ''),
- s.learner_id::text
- ) AS learner_label,
- pc.persona_id AS card_persona_id,
- pc.code AS card_code,
- pc.version AS card_version,
- pc.status AS card_status,
- pc.display_name AS card_display_name,
- pc.difficulty AS card_difficulty,
- pc.theory_target AS card_theory_target,
- pc.demographics AS card_demographics,
- pc.presenting AS card_presenting,
- pc.history AS card_history,
- pc.big5 AS card_big5,
- pc.resistance AS card_resistance,
- pc.speech_style AS card_speech_style,
- pc.affect_baseline AS card_affect_baseline,
- pc.ccd AS card_ccd,
- pc.dsm5_dimensional AS card_dsm5_dimensional,
- pc.triggers AS card_triggers,
- pc.source_provenance AS card_source_provenance,
- pc.is_synthetic AS card_is_synthetic
+ f"""
+ SELECT {SESSION_PERSONA_SELECT_COLUMNS_SQL}
FROM app.sessions s
LEFT JOIN app.session_evaluation se ON se.session_id = s.id
- LEFT JOIN app.persona_card pc
- ON pc.persona_id = s.persona_id
- AND pc.version = s.persona_version
- LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
+ {SESSION_PERSONA_JOINS_SQL}
WHERE s.ended_at IS NOT NULL
AND s.ended_at <= now() - ($1::double precision * interval '1 second')
AND se.session_id IS NULL
@@ -1712,30 +1855,18 @@ async def list_sessions_missing_session_evaluation(
stale_seconds,
max(1, int(limit)),
)
+ session_ids = [str(row["id"]) for row in rows]
+ states_by_id, turns_by_id = await _fetch_session_runtime_rows(
+ conn, session_ids
+ )
sessions: list[InProcSession] = []
for row in rows:
session_id = str(row["id"])
- state_row = await conn.fetchrow(
- """
- SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
- ideation_stage, turns_in_stage, affect_state
- FROM app.session_state
- WHERE session_id = $1::uuid
- """,
- session_id,
+ sess = _session_from_rows(
+ row,
+ states_by_id.get(session_id),
+ turns_by_id.get(session_id, []),
)
- turn_rows = await conn.fetch(
- """
- SELECT id, seq, speaker, stage, text, text_masked, created_at,
- llm_provider, model, tokens_in, tokens_out, cost_usd,
- audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
- FROM app.turns
- WHERE session_id = $1::uuid
- ORDER BY seq
- """,
- session_id,
- )
- sess = _session_from_rows(row, state_row, turn_rows)
if sess is not None:
sessions.append(sess)
return sessions, True
@@ -1793,7 +1924,9 @@ async def load_case_worksheet(
)
if row is None:
return (
- _CASE_WORKSHEET_CACHE.get(session_id) if runtime_fallback_allowed() else None
+ _CASE_WORKSHEET_CACHE.get(session_id)
+ if runtime_fallback_allowed()
+ else None
), False
payload = dict(row["payload"] or {})
payload.setdefault("savedAt", _iso_dt(row["updated_at"]))
@@ -1909,7 +2042,9 @@ async def get_live_coach_quota(
return _live_coach_quota_payload(remaining), True
except Exception:
require_runtime_fallback_allowed("live coach quota")
- return _live_coach_quota_payload(_live_coach_cached_remaining(session_id)), False
+ return _live_coach_quota_payload(
+ _live_coach_cached_remaining(session_id)
+ ), False
async def list_live_coach_credit_events(
@@ -1992,7 +2127,8 @@ async def record_live_coach_recharge(
require_runtime_fallback_allowed("live coach recharge")
events = _LIVE_COACH_EVENT_CACHE.setdefault(session_id, [])
if any(
- event.get("event_type") == "recharge" and int(event.get("turn_seq") or 0) == int(turn_seq)
+ event.get("event_type") == "recharge"
+ and int(event.get("turn_seq") or 0) == int(turn_seq)
for event in events
):
return None, False
@@ -2094,8 +2230,7 @@ async def list_session_review_statuses(
session_ids,
)
return {
- str(row["session_id"]): _review_status_from_row(row)
- for row in rows
+ str(row["session_id"]): _review_status_from_row(row) for row in rows
}, True
except Exception:
require_runtime_fallback_allowed("session review status list")
@@ -2326,7 +2461,9 @@ async def list_session_archives(
""",
session_ids,
)
- return {str(row["session_id"]): _archive_record_from_row(row) for row in rows}, True
+ return {
+ str(row["session_id"]): _archive_record_from_row(row) for row in rows
+ }, True
except Exception:
require_runtime_fallback_allowed("session archive list")
return {
@@ -2385,7 +2522,9 @@ async def set_session_archived(
session_id,
learner_id,
)
- return (_archive_record_from_row(deleted) if deleted is not None else None), True
+ return (
+ _archive_record_from_row(deleted) if deleted is not None else None
+ ), True
except Exception:
require_runtime_fallback_allowed("session archive update")
return (_SESSION_ARCHIVE_CACHE.get(session_id) if archived else None), False
@@ -2420,10 +2559,15 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N
ended=ended_at is not None,
prev_rapport_credit=float(row["prev_rapport_credit"] or 0.0),
learner_label=_clean_text(_row_value(row, "learner_label")),
+ goal_stages=[
+ str(v) for v in _json_list_payload(_row_value(row, "session_goals"))
+ ],
)
-async def _upsert_state(conn, session_id: str, state: state_machine.SessionState) -> None:
+async def _upsert_state(
+ conn, session_id: str, state: state_machine.SessionState
+) -> None:
await conn.execute(
"""
INSERT INTO app.session_state (
@@ -2493,6 +2637,7 @@ async def create_session(
persona_id: str | None = None,
persona_version: int | None = None,
case_id: str | None = None,
+ goal_stages: list[str] | None = None,
) -> InProcSession | None:
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
try:
@@ -2537,12 +2682,12 @@ async def create_session(
INSERT INTO app.sessions (
runtime_case_id, case_id, learner_id, persona_id, persona_version,
persona_code, persona_display_name, persona_difficulty,
- session_no, theory_mode, stage_path, prev_rapport_credit
+ session_no, theory_mode, stage_path, prev_rapport_credit, session_goals
)
VALUES (
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5,
$6, $7, $8,
- $9, $10, '[]'::jsonb, $11
+ $9, $10, '[]'::jsonb, $11, $12::jsonb
)
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
@@ -2558,6 +2703,7 @@ async def create_session(
session_no,
theory_mode,
carry_rapport,
+ json.dumps(list(goal_stages or []), ensure_ascii=False),
)
await _upsert_state(conn, str(row["id"]), state)
return InProcSession(
@@ -2574,6 +2720,7 @@ async def create_session(
turns=[],
ended=False,
prev_rapport_credit=carry_rapport,
+ goal_stages=list(goal_stages or []),
)
except Exception:
require_runtime_fallback_allowed("session creation")
@@ -2595,41 +2742,10 @@ async def load_session(
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
- """
- SELECT
- s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
- s.session_no, s.theory_mode, s.started_at, s.ended_at,
- s.prev_rapport_credit,
- COALESCE(
- NULLIF(learner.display_name, ''),
- NULLIF(learner.nickname, ''),
- NULLIF(learner.email, ''),
- s.learner_id::text
- ) AS learner_label,
- pc.persona_id AS card_persona_id,
- pc.code AS card_code,
- pc.version AS card_version,
- pc.status AS card_status,
- pc.display_name AS card_display_name,
- pc.difficulty AS card_difficulty,
- pc.theory_target AS card_theory_target,
- pc.demographics AS card_demographics,
- pc.presenting AS card_presenting,
- pc.history AS card_history,
- pc.big5 AS card_big5,
- pc.resistance AS card_resistance,
- pc.speech_style AS card_speech_style,
- pc.affect_baseline AS card_affect_baseline,
- pc.ccd AS card_ccd,
- pc.dsm5_dimensional AS card_dsm5_dimensional,
- pc.triggers AS card_triggers,
- pc.source_provenance AS card_source_provenance,
- pc.is_synthetic AS card_is_synthetic
+ f"""
+ SELECT {SESSION_PERSONA_SELECT_COLUMNS_SQL}
FROM app.sessions s
- LEFT JOIN app.persona_card pc
- ON pc.persona_id = s.persona_id
- AND pc.version = s.persona_version
- LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
+ {SESSION_PERSONA_JOINS_SQL}
WHERE s.id = $1::uuid
""",
session_id,
@@ -2638,27 +2754,14 @@ async def load_session(
return None
if row["ended_at"] is not None and not allow_ended:
return None
- state_row = await conn.fetchrow(
- """
- SELECT stage, turn_seq, effective_openness, rapport_credit, resistance,
- ideation_stage, turns_in_stage, affect_state
- FROM app.session_state
- WHERE session_id = $1::uuid
- """,
- session_id,
+ states_by_id, turns_by_id = await _fetch_session_runtime_rows(
+ conn, [session_id]
)
- turn_rows = await conn.fetch(
- """
- SELECT id, seq, speaker, stage, text, text_masked, created_at,
- llm_provider, model, tokens_in, tokens_out, cost_usd,
- audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
- FROM app.turns
- WHERE session_id = $1::uuid
- ORDER BY seq
- """,
- session_id,
+ sess = _session_from_rows(
+ row,
+ states_by_id.get(session_id),
+ turns_by_id.get(session_id, []),
)
- sess = _session_from_rows(row, state_row, turn_rows)
if sess is not None:
await _record_session_read_audit(
conn,
@@ -2926,15 +3029,21 @@ async def _upsert_pinned_fact_candidates(conn: Any, sess: InProcSession) -> None
)
-def _build_session_summary_write(sess: InProcSession, carry: memory.CarryOver) -> SessionSummaryWrite:
+def _build_session_summary_write(
+ sess: InProcSession, carry: memory.CarryOver
+) -> SessionSummaryWrite:
digest_input = memory.build_session_digest_input(
session_id=sess.session_id,
case_id=sess.case_id,
session_no=sess.session_no,
masked_turns=sess.masked_turns(visible_to="client"),
- open_threads=carry.compression_job.open_threads if carry.compression_job else [],
+ open_threads=carry.compression_job.open_threads
+ if carry.compression_job
+ else [],
+ )
+ digest_result = memory.build_fallback_digest_result(
+ digest_input, end_state=carry.end_state
)
- digest_result = memory.build_fallback_digest_result(digest_input, end_state=carry.end_state)
return SessionSummaryWrite(
session_id=sess.session_id,
case_id=sess.case_id,
@@ -3072,8 +3181,12 @@ async def _list_sessions(
) -> tuple[list[InProcSession], bool]:
try:
get_pool()
- learner_filter = "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
- query_args: list[object] = [principal.user_id] if principal.role.value == "learner" else []
+ learner_filter = (
+ "WHERE s.learner_id = $1::uuid" if principal.role.value == "learner" else ""
+ )
+ query_args: list[object] = (
+ [principal.user_id] if principal.role.value == "learner" else []
+ )
limit_clause = ""
if session_limit is not None:
query_args.append(max(1, int(session_limit)))
@@ -3085,40 +3198,9 @@ async def _list_sessions(
) as conn:
rows = await conn.fetch(
f"""
- SELECT
- s.id, s.runtime_case_id, s.case_id, s.learner_id, s.persona_code,
- s.session_no, s.theory_mode, s.started_at, s.ended_at,
- s.prev_rapport_credit,
- COALESCE(
- NULLIF(learner.display_name, ''),
- NULLIF(learner.nickname, ''),
- NULLIF(learner.email, ''),
- s.learner_id::text
- ) AS learner_label,
- pc.persona_id AS card_persona_id,
- pc.code AS card_code,
- pc.version AS card_version,
- pc.status AS card_status,
- pc.display_name AS card_display_name,
- pc.difficulty AS card_difficulty,
- pc.theory_target AS card_theory_target,
- pc.demographics AS card_demographics,
- pc.presenting AS card_presenting,
- pc.history AS card_history,
- pc.big5 AS card_big5,
- pc.resistance AS card_resistance,
- pc.speech_style AS card_speech_style,
- pc.affect_baseline AS card_affect_baseline,
- pc.ccd AS card_ccd,
- pc.dsm5_dimensional AS card_dsm5_dimensional,
- pc.triggers AS card_triggers,
- pc.source_provenance AS card_source_provenance,
- pc.is_synthetic AS card_is_synthetic
+ SELECT {SESSION_PERSONA_SELECT_COLUMNS_SQL}
FROM app.sessions s
- LEFT JOIN app.persona_card pc
- ON pc.persona_id = s.persona_id
- AND pc.version = s.persona_version
- LEFT JOIN app.app_user learner ON learner.user_id = s.learner_id
+ {SESSION_PERSONA_JOINS_SQL}
{learner_filter}
ORDER BY s.started_at DESC
{limit_clause}
@@ -3127,32 +3209,9 @@ async def _list_sessions(
)
# N+1 제거: 세션별 state/turns fetch 루프(1+2N 왕복) 대신 id 집합으로 한 번씩 배치 조회.
session_ids = [str(row["id"]) for row in rows]
- states_by_id: dict[str, Any] = {}
- turns_by_id: dict[str, list] = {}
- if session_ids:
- state_rows = await conn.fetch(
- """
- SELECT session_id, stage, turn_seq, effective_openness, rapport_credit, resistance,
- ideation_stage, turns_in_stage, affect_state
- FROM app.session_state
- WHERE session_id = ANY($1::uuid[])
- """,
- session_ids,
- )
- states_by_id = {str(state_row["session_id"]): state_row for state_row in state_rows}
- turn_rows_all = await conn.fetch(
- """
- SELECT session_id, id, seq, speaker, stage, text, text_masked, created_at,
- llm_provider, model, tokens_in, tokens_out, cost_usd,
- audio_ref, silence_ms, speech_rate, barge_in, provider_events, visible_to
- FROM app.turns
- WHERE session_id = ANY($1::uuid[])
- ORDER BY session_id, seq
- """,
- session_ids,
- )
- for turn_row in turn_rows_all:
- turns_by_id.setdefault(str(turn_row["session_id"]), []).append(turn_row)
+ states_by_id, turns_by_id = await _fetch_session_runtime_rows(
+ conn, session_ids
+ )
sessions: list[InProcSession] = []
for row in rows:
session_id = str(row["id"])
diff --git a/apps/api/app/session_read_model.py b/apps/api/app/session_read_model.py
index b1f923c..dd9d4e8 100644
--- a/apps/api/app/session_read_model.py
+++ b/apps/api/app/session_read_model.py
@@ -16,13 +16,13 @@ from typing import Any, Literal, Optional, cast
from pydantic import BaseModel, Field
from .config import settings
-from .services import guardrail, session_metrics
+from .services import guardrail, session_metrics, state_machine
from .stage_contract import (
ReviewPhaseKey,
StageLabel,
review_phase_key,
stage_label as _normalize_stage_label,
- stage_label_or_none,
+ stage_label_or_none as stage_label_or_none,
)
from .store import InProcSession, TurnRecord
@@ -106,6 +106,8 @@ class LearnerDashboardPersonaProgress(BaseModel):
latest_stage: StageLabel | None = None
latest_score: float | None = None
trend: str = "insufficient"
+ # P2 누적 게이지: 이 페르소나와 쌓아온 라포 누적(전 주기 임계 기준 %).
+ rapport_percent: int = 0
class LearnerDashboardAchievement(BaseModel):
@@ -147,6 +149,75 @@ class SessionArchiveResponse(BaseModel):
session: LearnerSessionSummary
+class SessionStageProgress(BaseModel):
+ """단계별 누적 게이지(P2). 상태머신 결정론 수치의 파생값만 담는다."""
+
+ stage: StageLabel
+ percent: int = 0
+ achieved: bool = False
+ is_goal: bool = False
+
+
+class SessionProgress(BaseModel):
+ """회기 진행 상세(P2). 내부 원값 명칭 대신 학습자-안전 파생 %만 노출한다."""
+
+ stages: list[SessionStageProgress] = Field(default_factory=list)
+ rapport_percent: int = 0
+ rapport_delta_percent: int = 0
+ resistance_percent: int = 0
+ openness_percent: int = 0
+
+
+# 전 주기(라포→정리) 기준 라포 만점: 마지막 전이 임계(개입→정리)를 100으로 본다.
+_FULL_CYCLE_RAPPORT = max(state_machine.STAGE_ADVANCE_RAPPORT.values())
+
+
+def _rapport_percent(credit: float) -> int:
+ if _FULL_CYCLE_RAPPORT <= 0:
+ return 0
+ return max(0, min(100, int(round(float(credit or 0.0) / _FULL_CYCLE_RAPPORT * 100))))
+
+
+def build_session_progress(
+ state: state_machine.SessionState,
+ *,
+ prev_rapport_credit: float = 0.0,
+ goal_stages: list[str] | None = None,
+) -> SessionProgress:
+ """상태머신 수치 → 단계 누적 게이지/상세 수치 파생(순수함수, LLM 미경유).
+
+ 누적성: rapport_credit 은 회기 간 ×0.7 이월되므로 다음 회기 게이지는 이월분에서 시작한다.
+ """
+ goals = {stage_label(goal) for goal in (goal_stages or [])}
+ order = state_machine.STAGE_ORDER
+ cur_idx = order.index(state.stage)
+ stages: list[SessionStageProgress] = []
+ for i, st in enumerate(order):
+ label = stage_label(st)
+ if i < cur_idx:
+ pct, achieved = 100, True
+ elif i > cur_idx:
+ pct, achieved = 0, False
+ elif st is state_machine.Stage.CLOSE:
+ pct, achieved = 100, True
+ else:
+ need = state_machine.STAGE_ADVANCE_RAPPORT.get(st, 1.0)
+ ratio = min(1.0, float(state.rapport_credit or 0.0) / need) if need > 0 else 0.0
+ pct, achieved = min(99, int(round(ratio * 99))), False
+ stages.append(
+ SessionStageProgress(stage=label, percent=pct, achieved=achieved, is_goal=label in goals)
+ )
+ rapport_pct = _rapport_percent(state.rapport_credit)
+ prev_pct = _rapport_percent(prev_rapport_credit)
+ return SessionProgress(
+ stages=stages,
+ rapport_percent=rapport_pct,
+ rapport_delta_percent=max(0, rapport_pct - prev_pct),
+ resistance_percent=max(0, min(100, int(round(float(state.resistance or 0.0) * 100)))),
+ openness_percent=max(0, min(100, int(round(float(state.effective_openness or 0.0) * 100)))),
+ )
+
+
class SessionDetailTurn(BaseModel):
turn_seq: int
speaker: Literal["learner", "client"]
@@ -168,6 +239,12 @@ class SessionDetailResponse(BaseModel):
ended_at: str | None = None
turns: list[SessionDetailTurn] = Field(default_factory=list)
review_ready: bool = False
+ # 시간 기반 회기(2026-07-13 회의 P1): 새로고침 복원 시 타이머·목표 표시의 기준.
+ goal_stages: list[StageLabel] = Field(default_factory=list)
+ duration_limit_seconds: int = 0
+ warning_before_end_seconds: int = 0
+ # P2 단계 누적 게이지·상세 수치.
+ progress: SessionProgress | None = None
class ReviewClient(BaseModel):
@@ -537,6 +614,7 @@ def dashboard_persona_progress(
latest_stage=stage_label(latest.state.stage),
latest_score=growth.latest_score if growth else None,
trend=growth.trend if growth else "insufficient",
+ rapport_percent=_rapport_percent(latest.state.rapport_credit),
)
)
return sorted(
@@ -643,6 +721,14 @@ def session_detail(
for turn in turns
],
review_ready=review_ready,
+ goal_stages=[stage_label(goal) for goal in (sess.goal_stages or [])],
+ duration_limit_seconds=max(0, settings.session_duration_minutes) * 60,
+ warning_before_end_seconds=max(0, settings.session_warning_minutes) * 60,
+ progress=build_session_progress(
+ sess.state,
+ prev_rapport_credit=sess.prev_rapport_credit,
+ goal_stages=list(sess.goal_stages or []),
+ ),
)
diff --git a/apps/api/app/store.py b/apps/api/app/store.py
index a18330b..f28445d 100644
--- a/apps/api/app/store.py
+++ b/apps/api/app/store.py
@@ -10,7 +10,7 @@ DB(NAS Postgres)가 단일 SoR 이지만(db.py), Docker off 개발/시연에서
from __future__ import annotations
import time
-from dataclasses import asdict, dataclass, field
+from dataclasses import dataclass, field
from decimal import Decimal
from typing import Optional
from uuid import uuid4
@@ -69,6 +69,7 @@ class InProcSession:
ended: bool = False
prev_rapport_credit: float = 0.0 # carry-over delta 계산용
learner_label: str | None = None
+ goal_stages: list[str] = field(default_factory=list) # 이번 회기 목표 단계(학습자 선택, 최대 4)
def turns_visible_to(self, role: str) -> list[TurnRecord]:
return [turn for turn in self.turns if turn.is_visible_to(role)]
@@ -97,6 +98,7 @@ class SessionStore:
state: SessionState,
session_no: int = 1,
carry_rapport: float = 0.0,
+ goal_stages: list[str] | None = None,
) -> InProcSession:
session_id = uuid4().hex
case_id = uuid4().hex
@@ -110,6 +112,7 @@ class SessionStore:
state=state,
session_no=session_no,
prev_rapport_credit=carry_rapport,
+ goal_stages=list(goal_stages or []),
)
self._sessions[session_id] = s
return s
diff --git a/apps/api/app/taxonomy.py b/apps/api/app/taxonomy.py
index 7242255..71f93e6 100644
--- a/apps/api/app/taxonomy.py
+++ b/apps/api/app/taxonomy.py
@@ -26,7 +26,9 @@ from dataclasses import dataclass, field
from enum import Enum
from typing import Optional
-TAXONOMY_VERSION = "1.0.0" # 라벨 코드 불변 보장 버전. 새 라벨 추가 시 minor++.
+TAXONOMY_VERSION = "1.1.0" # 라벨 코드 불변 보장 버전. 새 라벨 추가 시 minor++.
+# 1.1.0 (2026-07-15): 연구팀 태깅 축어록 4종(첫회기 BPS·게슈탈트 3회기·자해 DBT·우울 CBT)
+# 태그 정합을 위해 Technique 9종·ClientState 9종 append-only 추가. 기존 코드 불변.
# ════════════════════════════════════════════════════════════════════════════
@@ -94,16 +96,28 @@ class Technique(str, Enum):
RISK_ASSESSMENT = "risk_assessment" # 위험요인/자해·자살 탐색 (0615: "위험요인 탐색", "자해 위험 및 경험 탐색")
CONSENT_MOTIVATION_CHECK = "consent_motivation_check" # 동의/동기 확인 (0615: "동의확인, 동기수준 확인")
OPINION_CHECK = "opinion_check" # 내담자 의견·반응 확인 (0615: "내담자 의견 확인", "주호소 문제 재확인")
+ # 1.1.0 추가 (연구팀 태깅 자료 정합)
+ CLARIFICATION = "clarification" # 명료화 질문 (cbt: "이해를 명확하게 하기 위해 질문", ch3 다수)
+ SCALING = "scaling" # 감정/증상 수치화·척도 질문 (cbt: 감정 강도 0~100)
+ CHAIN_ANALYSIS = "chain_analysis" # 행동 체인분석 — 사건→감정→행동 사슬 탐색 (dbt)
# ── INTERVENTION ──
CONFRONTATION = "confrontation" # 직면 (0615: "기초자료를 활용하여 반응의 불일치에 직면시킴")
INTERPRETATION = "interpretation" # 해석 (0615: "모순의 의미를 해석함", "비언어적 자극 해석", "행동의 재해석")
+ # 1.1.0 추가
+ COGNITIVE_RESTRUCTURING = "cognitive_restructuring" # 인지재구성 — 이분법·파국화 확인, 연속선 사고, 핵심신념 작업 (cbt 55건)
+ BEHAVIORAL_ALTERNATIVE = "behavioral_alternative" # 대안행동 탐색·계획 (cbt)
+ COMMITMENT_STRATEGY = "commitment_strategy" # 서약/전념 전략 — 악마의 옹호자, 방해물 예상 (dbt)
+ HERE_AND_NOW_FOCUS = "here_and_now_focus" # 지금-여기 초점화 (gestalt: 현전·트래킹)
# ── STABILIZING ──
STABILIZATION = "stabilization" # 안정화 (0615: "안정화")
HOPE_INSTILLATION = "hope_instillation" # 희망고취 (0615: "동기부여, 희망고취", "희망 고취, 욕구 반영")
REINFORCEMENT = "reinforcement" # 강화 (0615: "강화")
NORMALIZATION = "normalization" # 정상화 (0615: "감정 반응을 노출하는 것을 정상화함")
+ # 1.1.0 추가
+ RESTATEMENT = "restatement" # 재진술 — 내용을 되돌려 확인 (cbt: 재진술 후 내담자 확인 대기)
+ SKILLS_COACHING = "skills_coaching" # 기술 코칭 — 고통감내·위기대처 기술 안내 (dbt)
# ── STRUCTURING ──
PRINCIPLE_EXPLANATION = "principle_explanation" # 상담원칙/비밀보장 설명 (0615: "상담원칙에 대한 설명", "비밀보장 제외 원칙 설명")
@@ -134,6 +148,16 @@ TECHNIQUE_CATEGORY: dict[Technique, TechniqueCategory] = {
Technique.PRINCIPLE_EXPLANATION: TechniqueCategory.STRUCTURING,
Technique.PSYCHOEDUCATION: TechniqueCategory.STRUCTURING,
Technique.HOMEWORK: TechniqueCategory.STRUCTURING,
+ # 1.1.0 추가
+ Technique.CLARIFICATION: TechniqueCategory.EXPLORATORY,
+ Technique.SCALING: TechniqueCategory.EXPLORATORY,
+ Technique.CHAIN_ANALYSIS: TechniqueCategory.EXPLORATORY,
+ Technique.COGNITIVE_RESTRUCTURING: TechniqueCategory.INTERVENTION,
+ Technique.BEHAVIORAL_ALTERNATIVE: TechniqueCategory.INTERVENTION,
+ Technique.COMMITMENT_STRATEGY: TechniqueCategory.INTERVENTION,
+ Technique.HERE_AND_NOW_FOCUS: TechniqueCategory.INTERVENTION,
+ Technique.RESTATEMENT: TechniqueCategory.RELATIONAL,
+ Technique.SKILLS_COACHING: TechniqueCategory.STABILIZING,
}
# 한글 표시명(UI 라벨 칩·리뷰 화면). docs/taxonomy.md 와 일치.
@@ -159,6 +183,16 @@ TECHNIQUE_KO: dict[Technique, str] = {
Technique.PRINCIPLE_EXPLANATION: "상담원칙 설명",
Technique.PSYCHOEDUCATION: "심리교육",
Technique.HOMEWORK: "과제 부여",
+ # 1.1.0 추가
+ Technique.CLARIFICATION: "명료화",
+ Technique.SCALING: "수치화 질문",
+ Technique.CHAIN_ANALYSIS: "체인분석",
+ Technique.COGNITIVE_RESTRUCTURING: "인지재구성",
+ Technique.BEHAVIORAL_ALTERNATIVE: "대안행동 탐색",
+ Technique.COMMITMENT_STRATEGY: "전념 전략",
+ Technique.HERE_AND_NOW_FOCUS: "지금-여기 초점화",
+ Technique.RESTATEMENT: "재진술",
+ Technique.SKILLS_COACHING: "기술 코칭",
}
@@ -183,6 +217,16 @@ class ClientState(str, Enum):
RESPONDS_TO_EXPLORATION = "responds_to_exploration" # 탐색에 반응함 (0615: "상담자의 탐색에 반응함")
EXPRESSES_PLAN = "expresses_plan" # 자기 계획/욕구 표현 (0615: "자신의 계획을 표현함")
DEFENSE_LOOSENING = "defense_loosening" # 방어가 서서히 풀림 (0615: "방어가 서서히 풀어지고 있음")
+ # 1.1.0 추가 (연구팀 태깅 자료 정합)
+ COMPLIANT_SURFACE = "compliant_surface" # 표면 순응 — 겉으로 동의하나 내면 미변화 (ch2)
+ EXTERNALIZING = "externalizing" # 문제 외재화 — "아이가 학교만 가면"/"문제는 아내" (ch2·cbt)
+ SEEKS_GUIDANCE = "seeks_guidance" # 상담자 의견/지시를 구함 (cbt)
+ APPARENT_COMPETENCE = "apparent_competence" # 겉보기 유능함 — 회기 내 유능, 위기 시 붕괴 (dbt)
+ ACTIVE_PASSIVITY = "active_passivity" # 적극적 수동성 — 해결을 타인에게 위임 (dbt)
+ SELF_HARM_DISCLOSURE = "self_harm_disclosure" # 자해 보고 — 자살사고 인정과 구분 (dbt)
+ SOMATIC_COMPLAINT = "somatic_complaint" # 신체증상 호소 — 숨막힘·두근거림 (ch2)
+ AFFECT_MASKING = "affect_masking" # 감정 가림 — 웃음·무덤덤함으로 덮기 (ch3)
+ FOCUS_DRIFT_FUSION = "focus_drift_fusion" # 초점 이탈/융합 — 자기 문제가 타인 이야기로 회귀 (ch2·ch3)
CLIENT_STATE_KO: dict[ClientState, str] = {
ClientState.INVOLUNTARY: "비자발적 태도",
@@ -196,6 +240,16 @@ CLIENT_STATE_KO: dict[ClientState, str] = {
ClientState.RESPONDS_TO_EXPLORATION: "탐색에 반응",
ClientState.EXPRESSES_PLAN: "계획/욕구 표현",
ClientState.DEFENSE_LOOSENING: "방어 완화",
+ # 1.1.0 추가
+ ClientState.COMPLIANT_SURFACE: "표면 순응",
+ ClientState.EXTERNALIZING: "문제 외재화",
+ ClientState.SEEKS_GUIDANCE: "지시 요청",
+ ClientState.APPARENT_COMPETENCE: "겉보기 유능함",
+ ClientState.ACTIVE_PASSIVITY: "적극적 수동성",
+ ClientState.SELF_HARM_DISCLOSURE: "자해 보고",
+ ClientState.SOMATIC_COMPLAINT: "신체증상 호소",
+ ClientState.AFFECT_MASKING: "감정 가림",
+ ClientState.FOCUS_DRIFT_FUSION: "초점 이탈/융합",
}
diff --git a/apps/api/app/test_client_reply_quality.py b/apps/api/app/test_client_reply_quality.py
index 7df0a9b..259ab47 100644
--- a/apps/api/app/test_client_reply_quality.py
+++ b/apps/api/app/test_client_reply_quality.py
@@ -8,6 +8,28 @@ from .services import guardrail
class ClientReplyQualityGateTest(unittest.TestCase):
+ def test_blocks_self_harm_means_detail(self) -> None:
+ """자해 수단·방법 상세는 차단(1.1 확장 — DBT 자해 페르소나 대비)."""
+ samples = [
+ "커터로 했어요. 그게 제일 쉬워서요.",
+ "면도날을 항상 가지고 다녀요.",
+ "어젯밤에도 손목 긋고 잠들었어요.",
+ ]
+ for sample in samples:
+ with self.subTest(sample=sample):
+ result = guardrail.sanitize_client_reply(sample, ideation_stage=3, turn_seq=4)
+ self.assertTrue(result.needs_regeneration)
+ self.assertTrue(any(r.startswith("means_info:") for r in result.reasons))
+
+ def test_allows_boundary_setting_language(self) -> None:
+ """'선을 긋다'(경계 설정) 같은 정상 상담 표현은 차단하지 않는다."""
+ result = guardrail.sanitize_client_reply(
+ "이제는 엄마랑 선을 긋고 제 생활을 지키고 싶어요.",
+ ideation_stage=1,
+ turn_seq=4,
+ )
+ self.assertFalse(result.needs_regeneration)
+
def test_blocks_role_meta_speech_variants(self) -> None:
samples = [
"내담자 역할로 응답하겠습니다. 엄마가 가보라고 해서요.",
diff --git a/apps/api/app/test_dataset_export.py b/apps/api/app/test_dataset_export.py
index a9b403d..ee73e43 100644
--- a/apps/api/app/test_dataset_export.py
+++ b/apps/api/app/test_dataset_export.py
@@ -7,6 +7,7 @@ from pathlib import Path
from app.services.dataset_export import (
APPROVED_EXPORT_STATUS,
DRY_RUN_EXPORT_STATUS,
+ DatasetManifestInput,
ExportKeyMaps,
build_dataset_record,
build_manifest,
@@ -24,7 +25,9 @@ EXPORT_SCRIPT_PATH = REPO_ROOT / "scripts" / "export-recursive-dataset.py"
def load_export_script():
- spec = importlib.util.spec_from_file_location("export_recursive_dataset", EXPORT_SCRIPT_PATH)
+ spec = importlib.util.spec_from_file_location(
+ "export_recursive_dataset", EXPORT_SCRIPT_PATH
+ )
assert spec is not None and spec.loader is not None
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
@@ -92,7 +95,10 @@ class DatasetExportTests(unittest.TestCase):
keys=ExportKeyMaps(),
)
- self.assertEqual(record["supervisor_comments"], [{"kind": "note", "intent_deviation": {"severity": "low"}}])
+ self.assertEqual(
+ record["supervisor_comments"],
+ [{"kind": "note", "intent_deviation": {"severity": "low"}}],
+ )
self.assertNotIn("김서연", str(record))
self.assertNotIn("010-1234-5678", str(record))
@@ -109,38 +115,72 @@ class DatasetExportTests(unittest.TestCase):
self.assertIn("phone", kinds)
self.assertIn("national_id", kinds)
self.assertIn("blocked_field", kinds)
- self.assertFalse(any("learner@hs.ac.kr" in finding["sample"] for finding in findings))
- self.assertFalse(any("010-1234-5678" in finding["sample"] for finding in findings))
+ self.assertFalse(
+ any("learner@hs.ac.kr" in finding["sample"] for finding in findings)
+ )
+ self.assertFalse(
+ any("010-1234-5678" in finding["sample"] for finding in findings)
+ )
def test_agreement_metrics(self) -> None:
annotations = [
- {"item_id": "1", "labels": {"appropriateness": "good", "rapport_signal": 4.0}},
- {"item_id": "1", "labels": {"appropriateness": "good", "rapport_signal": 4.1}},
- {"item_id": "2", "labels": {"appropriateness": "bad", "rapport_signal": 2.0}},
- {"item_id": "2", "labels": {"appropriateness": "bad", "rapport_signal": 2.1}},
- {"item_id": "3", "labels": {"appropriateness": "good", "rapport_signal": 5.0}},
- {"item_id": "3", "labels": {"appropriateness": "good", "rapport_signal": 5.0}},
+ {
+ "item_id": "1",
+ "labels": {"appropriateness": "good", "rapport_signal": 4.0},
+ },
+ {
+ "item_id": "1",
+ "labels": {"appropriateness": "good", "rapport_signal": 4.1},
+ },
+ {
+ "item_id": "2",
+ "labels": {"appropriateness": "bad", "rapport_signal": 2.0},
+ },
+ {
+ "item_id": "2",
+ "labels": {"appropriateness": "bad", "rapport_signal": 2.1},
+ },
+ {
+ "item_id": "3",
+ "labels": {"appropriateness": "good", "rapport_signal": 5.0},
+ },
+ {
+ "item_id": "3",
+ "labels": {"appropriateness": "good", "rapport_signal": 5.0},
+ },
]
self.assertEqual(cohen_kappa(annotations, "appropriateness"), 1.0)
- self.assertGreater(intraclass_correlation(annotations, "rapport_signal") or 0, 0.9)
+ self.assertGreater(
+ intraclass_correlation(annotations, "rapport_signal") or 0, 0.9
+ )
def test_manifest_gate_blocks_unapproved_approved_status(self) -> None:
with self.assertRaisesRegex(ValueError, "PII scan must pass"):
build_manifest(
- export_id="phase3-rl-seed-test",
- dataset_name="vignette_phase3_recursive_learning_seed",
- export_status=APPROVED_EXPORT_STATUS,
- purpose="test",
- records=[],
- jsonl_path="03-export/anonymized_dataset.jsonl",
- jsonl_sha256="",
- pii_findings=[{"kind": "email", "path": "$.text_masked", "sample": ""}],
- participants_included=0,
- agreement={"kappa": 0.59, "icc": 0.74, "gold_status": "not_gold"},
+ DatasetManifestInput(
+ export_id="phase3-rl-seed-test",
+ dataset_name="vignette_phase3_recursive_learning_seed",
+ export_status=APPROVED_EXPORT_STATUS,
+ purpose="test",
+ records=[],
+ jsonl_path="03-export/anonymized_dataset.jsonl",
+ jsonl_sha256="",
+ pii_findings=[
+ {
+ "kind": "email",
+ "path": "$.text_masked",
+ "sample": "",
+ }
+ ],
+ participants_included=0,
+ agreement={"kappa": 0.59, "icc": 0.74, "gold_status": "not_gold"},
+ )
)
- def test_manifest_gate_requires_withdrawal_and_recursive_consent_scope(self) -> None:
+ def test_manifest_gate_requires_withdrawal_and_recursive_consent_scope(
+ self,
+ ) -> None:
manifest = {
"export_status": APPROVED_EXPORT_STATUS,
"pii_scan": {"status": "pass"},
@@ -172,15 +212,17 @@ class DatasetExportTests(unittest.TestCase):
write_jsonl([record], path)
digest = sha256_file(path)
manifest = build_manifest(
- export_id="phase3-rl-seed-test",
- dataset_name="vignette_phase3_recursive_learning_seed",
- export_status=DRY_RUN_EXPORT_STATUS,
- purpose="test",
- records=[record],
- jsonl_path="03-export/anonymized_dataset.jsonl",
- jsonl_sha256=digest,
- pii_findings=[],
- participants_included=1,
+ DatasetManifestInput(
+ export_id="phase3-rl-seed-test",
+ dataset_name="vignette_phase3_recursive_learning_seed",
+ export_status=DRY_RUN_EXPORT_STATUS,
+ purpose="test",
+ records=[record],
+ jsonl_path="03-export/anonymized_dataset.jsonl",
+ jsonl_sha256=digest,
+ pii_findings=[],
+ participants_included=1,
+ )
)
self.assertEqual(manifest["files"][0]["rows"], 1)
self.assertEqual(manifest["files"][0]["sha256"], digest)
diff --git a/apps/api/app/test_evaluation_persistence.py b/apps/api/app/test_evaluation_persistence.py
index ca7cc2c..56d0c1c 100644
--- a/apps/api/app/test_evaluation_persistence.py
+++ b/apps/api/app/test_evaluation_persistence.py
@@ -43,6 +43,8 @@ class FakeMissingEvaluationConn:
self.fetches.append((query, args))
if "FROM app.sessions s" in query:
return [{"id": "11111111-1111-1111-1111-111111111111"}]
+ if "FROM app.session_state" in query:
+ return [{"session_id": "11111111-1111-1111-1111-111111111111"}]
if "FROM app.turns" in query:
return []
raise AssertionError(f"unexpected fetch query: {query}")
@@ -521,8 +523,12 @@ class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("se.session_id IS NULL", session_query)
self.assertIn("'client' = ANY(t.visible_to)", session_query)
self.assertIn("ORDER BY s.ended_at ASC", session_query)
- self.assertEqual(conn.fetchrows[0][1], ("11111111-1111-1111-1111-111111111111",))
- self.assertIn("FROM app.turns", conn.fetches[1][0])
+ self.assertEqual(
+ conn.fetches[1][1],
+ (["11111111-1111-1111-1111-111111111111"],),
+ )
+ self.assertIn("FROM app.session_state", conn.fetches[1][0])
+ self.assertIn("FROM app.turns", conn.fetches[2][0])
async def test_record_llm_call_audit_inserts_metadata_only(self) -> None:
conn = FakeEvaluationConn()
diff --git a/apps/api/app/test_live_coach_privacy.py b/apps/api/app/test_live_coach_privacy.py
index b82a311..f1f9bdf 100644
--- a/apps/api/app/test_live_coach_privacy.py
+++ b/apps/api/app/test_live_coach_privacy.py
@@ -80,24 +80,74 @@ class LiveCoachQuotaRechargeTest(unittest.TestCase):
self.assertTrue(should_recharge)
self.assertIn("개방도", reason)
+ # 성과 없음(개방도 하락) + 페이싱 주기 아님 → 미충전
should_recharge, _ = turn_runtime.should_recharge_live_coach_credit(
{"appropriateness": "pos", "rapport_signal": 0.45},
before,
state_machine.SessionState(
stage=state_machine.Stage.RAPPORT,
turn_seq=3,
- effective_openness=0.16,
+ effective_openness=0.14,
),
)
self.assertFalse(should_recharge)
- should_recharge, _ = turn_runtime.should_recharge_live_coach_credit(
+ def test_recharge_allows_neutral_with_strong_rapport_and_gain(self) -> None:
+ before = state_machine.SessionState(
+ stage=state_machine.Stage.RAPPORT,
+ turn_seq=2,
+ effective_openness=0.15,
+ )
+ after = state_machine.SessionState(
+ stage=state_machine.Stage.RAPPORT,
+ turn_seq=3,
+ effective_openness=0.19,
+ )
+ should_recharge, reason = turn_runtime.should_recharge_live_coach_credit(
{"appropriateness": "neutral", "rapport_signal": 0.8},
before,
after,
)
+ self.assertTrue(should_recharge)
+ self.assertIn("라포", reason)
+
+ def test_recharge_warn_turn_does_not_recharge_off_cycle(self) -> None:
+ before = state_machine.SessionState(
+ stage=state_machine.Stage.RAPPORT,
+ turn_seq=2,
+ effective_openness=0.15,
+ )
+ after = state_machine.SessionState(
+ stage=state_machine.Stage.RAPPORT,
+ turn_seq=3,
+ effective_openness=0.19,
+ )
+ should_recharge, _ = turn_runtime.should_recharge_live_coach_credit(
+ {"appropriateness": "warn", "rapport_signal": 0.1},
+ before,
+ after,
+ )
self.assertFalse(should_recharge)
+ def test_pacing_recharge_every_n_turns_even_without_evaluation(self) -> None:
+ before = state_machine.SessionState(
+ stage=state_machine.Stage.RAPPORT,
+ turn_seq=5,
+ effective_openness=0.15,
+ )
+ after = state_machine.SessionState(
+ stage=state_machine.Stage.RAPPORT,
+ turn_seq=turn_runtime._LIVE_COACH_PACING_RECHARGE_EVERY_TURNS,
+ effective_openness=0.14,
+ )
+ should_recharge, reason = turn_runtime.should_recharge_live_coach_credit(
+ None,
+ before,
+ after,
+ )
+ self.assertTrue(should_recharge)
+ self.assertIn("턴", reason)
+
if __name__ == "__main__":
unittest.main()
diff --git a/apps/api/app/test_rbac_idor.py b/apps/api/app/test_rbac_idor.py
index cc9c361..24d5a2d 100644
--- a/apps/api/app/test_rbac_idor.py
+++ b/apps/api/app/test_rbac_idor.py
@@ -611,11 +611,8 @@ class TeacherAdminAuditTest(unittest.IsolatedAsyncioTestCase):
learner_id = "00000000-0000-0000-0000-000000000808"
sess = _session(session_id=session_id, learner_id=learner_id)
conn = _FakeConn(
- fetchrow_results=[
- {"id": session_id, "ended_at": None},
- None,
- ],
- fetch_results=[[]],
+ fetchrow_results=[{"id": session_id, "ended_at": None}],
+ fetch_results=[[], []],
)
acquire_calls: list[dict[str, Any]] = []
diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py
index a00661d..6c43762 100644
--- a/apps/api/app/test_runtime_policy.py
+++ b/apps/api/app/test_runtime_policy.py
@@ -17,6 +17,7 @@ from .routes import admin as admin_routes
from .routes import eval as eval_routes
from .routes import kb as kb_routes
from .routes import users as users_routes
+from .services import notifications
@contextmanager
@@ -48,15 +49,21 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertFalse(durable)
self.assertEqual(users, [])
- async def test_staging_blocks_auth_registry_fallback_when_db_pool_missing(self) -> None:
+ async def test_staging_blocks_auth_registry_fallback_when_db_pool_missing(
+ self,
+ ) -> None:
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
await auth_sessions.list_managed_users()
self.assertEqual(caught.exception.status_code, 503)
- self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
+ self.assertIn(
+ "runtime fallback is disabled in staging", caught.exception.detail
+ )
- async def test_prod_blocks_browser_session_creation_when_db_pool_missing(self) -> None:
+ async def test_prod_blocks_browser_session_creation_when_db_pool_missing(
+ self,
+ ) -> None:
with environment("prod"):
with self.assertRaises(HTTPException) as caught:
await auth_sessions.create_session(
@@ -68,7 +75,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("runtime fallback is disabled in prod", caught.exception.detail)
- async def test_staging_blocks_session_store_fallback_when_db_pool_missing(self) -> None:
+ async def test_staging_blocks_session_store_fallback_when_db_pool_missing(
+ self,
+ ) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000001",
role=Role.LEARNER,
@@ -81,9 +90,13 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
await session_persistence.list_recent_sessions(principal)
self.assertEqual(caught.exception.status_code, 503)
- self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
+ self.assertIn(
+ "runtime fallback is disabled in staging", caught.exception.detail
+ )
- async def test_staging_blocks_eval_store_cache_fallback_when_db_pool_missing(self) -> None:
+ async def test_staging_blocks_eval_store_cache_fallback_when_db_pool_missing(
+ self,
+ ) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000002",
role=Role.TEACHER,
@@ -93,10 +106,14 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
)
with environment("staging"):
with self.assertRaises(HTTPException) as caught:
- await eval_routes.get_session_evaluation("missing-session-id", principal)
+ await eval_routes.get_session_evaluation(
+ "missing-session-id", principal
+ )
self.assertEqual(caught.exception.status_code, 503)
- self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
+ self.assertIn(
+ "runtime fallback is disabled in staging", caught.exception.detail
+ )
async def test_staging_kb_search_fails_closed_when_db_pool_missing(self) -> None:
with environment("staging"):
@@ -106,7 +123,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("DB not ready", caught.exception.detail)
- async def test_staging_blocks_user_preferences_fallback_when_db_pool_missing(self) -> None:
+ async def test_staging_blocks_user_preferences_fallback_when_db_pool_missing(
+ self,
+ ) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000003",
role=Role.LEARNER,
@@ -119,9 +138,13 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
await users_routes.get_preferences(principal)
self.assertEqual(caught.exception.status_code, 503)
- self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
+ self.assertIn(
+ "runtime fallback is disabled in staging", caught.exception.detail
+ )
- async def test_dev_allows_user_preferences_fallback_when_db_pool_missing(self) -> None:
+ async def test_dev_allows_user_preferences_fallback_when_db_pool_missing(
+ self,
+ ) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000004",
role=Role.LEARNER,
@@ -134,7 +157,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(prefs.voice_preset_id, "soft-young-fem")
- async def test_prod_blocks_runtime_schema_bootstrap_ddl_when_schema_incomplete(self) -> None:
+ async def test_prod_blocks_runtime_schema_bootstrap_ddl_when_schema_incomplete(
+ self,
+ ) -> None:
class IncompleteConn:
async def fetchrow(self, *args, **kwargs):
return {
@@ -179,13 +204,80 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
def acquire(self):
return IncompleteAcquire()
- with environment("prod"), patch.object(auth_sessions, "get_pool", return_value=IncompletePool()):
+ with (
+ environment("prod"),
+ patch.object(auth_sessions, "get_pool", return_value=IncompletePool()),
+ ):
with self.assertRaises(RuntimeError) as caught:
await auth_sessions.ensure_runtime_tables()
self.assertIn("runtime DB schema is incomplete", str(caught.exception))
- async def test_runtime_readiness_requires_all_turn_voice_metadata_columns(self) -> None:
+ async def test_prod_blocks_review_schema_bootstrap_ddl_when_schema_incomplete(
+ self,
+ ) -> None:
+ class IncompleteConn:
+ async def fetchrow(self, *args, **kwargs):
+ return {"ready": False}
+
+ async def execute(self, *args, **kwargs):
+ raise AssertionError("prod startup must not run review schema DDL")
+
+ class IncompleteAcquire:
+ async def __aenter__(self):
+ return IncompleteConn()
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return None
+
+ with (
+ environment("prod"),
+ patch.object(session_persistence, "get_pool", return_value=object()),
+ patch.object(
+ session_persistence, "acquire", return_value=IncompleteAcquire()
+ ),
+ ):
+ with self.assertRaises(RuntimeError) as caught:
+ await session_persistence.ensure_review_tables()
+
+ self.assertIn(
+ "review/evaluation runtime DB schema is incomplete", str(caught.exception)
+ )
+
+ async def test_prod_blocks_notification_schema_bootstrap_ddl_when_schema_incomplete(
+ self,
+ ) -> None:
+ class IncompleteConn:
+ async def fetchrow(self, *args, **kwargs):
+ return {"ready": False}
+
+ async def execute(self, *args, **kwargs):
+ raise AssertionError(
+ "prod startup must not run notification schema DDL"
+ )
+
+ class IncompleteAcquire:
+ async def __aenter__(self):
+ return IncompleteConn()
+
+ async def __aexit__(self, exc_type, exc, tb):
+ return None
+
+ with (
+ environment("prod"),
+ patch.object(notifications, "get_pool", return_value=object()),
+ patch.object(notifications, "acquire", return_value=IncompleteAcquire()),
+ ):
+ with self.assertRaises(RuntimeError) as caught:
+ await notifications.ensure_notification_tables()
+
+ self.assertIn(
+ "notification runtime DB schema is incomplete", str(caught.exception)
+ )
+
+ async def test_runtime_readiness_requires_all_turn_voice_metadata_columns(
+ self,
+ ) -> None:
class VoiceMetadataDriftConn:
def __init__(self) -> None:
self.query = ""
@@ -225,7 +317,13 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
ready = await auth_sessions._runtime_tables_ready(conn)
self.assertFalse(ready)
- for column in ("audio_ref", "silence_ms", "speech_rate", "barge_in", "provider_events"):
+ for column in (
+ "audio_ref",
+ "silence_ms",
+ "speech_rate",
+ "barge_in",
+ "provider_events",
+ ):
self.assertIn(column, conn.query)
self.assertIn("HAVING count(*) = 5", conn.query)
@@ -296,7 +394,10 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
def acquire(self):
return EmptyConfigAcquire()
- with environment("staging"), patch.object(admin_routes, "get_pool", return_value=EmptyConfigPool()):
+ with (
+ environment("staging"),
+ patch.object(admin_routes, "get_pool", return_value=EmptyConfigPool()),
+ ):
config = await admin_routes._current_engine_config()
self.assertFalse(config.durable)
@@ -309,14 +410,21 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
def acquire(self):
raise RuntimeError("db unavailable")
- with environment("staging"), patch.object(admin_routes, "get_pool", return_value=BrokenConfigPool()):
+ with (
+ environment("staging"),
+ patch.object(admin_routes, "get_pool", return_value=BrokenConfigPool()),
+ ):
with self.assertRaises(HTTPException) as caught:
await admin_routes._current_engine_config()
self.assertEqual(caught.exception.status_code, 503)
- self.assertIn("runtime fallback is disabled in staging", caught.exception.detail)
+ self.assertIn(
+ "runtime fallback is disabled in staging", caught.exception.detail
+ )
- async def test_prod_admin_health_marks_db_down_when_persistence_unavailable(self) -> None:
+ async def test_prod_admin_health_marks_db_down_when_persistence_unavailable(
+ self,
+ ) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000005",
role=Role.ADMIN,
@@ -334,10 +442,20 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
with (
environment("prod"),
- patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
+ patch.object(
+ admin_routes,
+ "_current_engine_config",
+ AsyncMock(return_value=engine_config),
+ ),
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
- patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
- patch.object(admin_routes.voice_service, "is_available", return_value=False),
+ patch.object(
+ admin_routes.engine_client,
+ "health_detail",
+ AsyncMock(return_value={"ok": True}),
+ ),
+ patch.object(
+ admin_routes.voice_service, "is_available", return_value=False
+ ),
):
health = await admin_routes.admin_health(principal)
@@ -346,7 +464,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(db.metric, "저장소 중단")
self.assertIn("DB 저장소", db.detail)
- async def test_dev_admin_health_labels_db_fallback_as_non_durable_runtime(self) -> None:
+ async def test_dev_admin_health_labels_db_fallback_as_non_durable_runtime(
+ self,
+ ) -> None:
principal = Principal(
user_id="00000000-0000-0000-0000-000000000006",
role=Role.ADMIN,
@@ -364,10 +484,20 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
with (
environment("dev"),
- patch.object(admin_routes, "_current_engine_config", AsyncMock(return_value=engine_config)),
+ patch.object(
+ admin_routes,
+ "_current_engine_config",
+ AsyncMock(return_value=engine_config),
+ ),
patch.object(admin_routes, "healthcheck", AsyncMock(return_value=False)),
- patch.object(admin_routes.engine_client, "health_detail", AsyncMock(return_value={"ok": True})),
- patch.object(admin_routes.voice_service, "is_available", return_value=False),
+ patch.object(
+ admin_routes.engine_client,
+ "health_detail",
+ AsyncMock(return_value={"ok": True}),
+ ),
+ patch.object(
+ admin_routes.voice_service, "is_available", return_value=False
+ ),
):
health = await admin_routes.admin_health(principal)
@@ -535,7 +665,9 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
voice_poc_sample_tts_enabled=False,
)
- self.assertEqual(cfg.frontend_origin_map["api-vnet.18ka.net"], "https://vnet.18ka.net")
+ self.assertEqual(
+ cfg.frontend_origin_map["api-vnet.18ka.net"], "https://vnet.18ka.net"
+ )
self.assertIn("https://vnet.18ka.net", cfg.cors_origins)
def test_non_dev_rejects_local_frontend_origin_map(self) -> None:
diff --git a/apps/api/app/test_runtime_schema_ssot.py b/apps/api/app/test_runtime_schema_ssot.py
new file mode 100644
index 0000000..2c06942
--- /dev/null
+++ b/apps/api/app/test_runtime_schema_ssot.py
@@ -0,0 +1,57 @@
+"""앱의 런타임 준비 계약과 infra SQL SSOT 사이의 회귀 검사."""
+
+from __future__ import annotations
+
+import re
+import unittest
+from pathlib import Path
+
+from .runtime_schema import NOTIFICATION_SCHEMA_CONTRACT, REVIEW_SCHEMA_CONTRACT
+
+
+REPO_ROOT = Path(__file__).resolve().parents[3]
+INFRA_SQL = "\n".join(
+ path.read_text(encoding="utf-8")
+ for path in sorted((REPO_ROOT / "infra" / "db" / "init").glob("*.sql"))
+)
+
+
+class RuntimeSchemaSsotTest(unittest.TestCase):
+ def test_runtime_contract_objects_are_owned_by_infra_sql(self) -> None:
+ for contract in (REVIEW_SCHEMA_CONTRACT, NOTIFICATION_SCHEMA_CONTRACT):
+ for relation in contract.relations:
+ schema, table = relation.split(".")
+ pattern = rf"CREATE TABLE IF NOT EXISTS\s+{re.escape(schema)}\.{re.escape(table)}\b"
+ self.assertRegex(
+ INFRA_SQL, pattern, msg=f"infra SQL missing relation: {relation}"
+ )
+
+ for qualified_name in contract.columns:
+ _, table, column = qualified_name.split(".")
+ table_mentions = [
+ block
+ for block in re.split(
+ r"(?=CREATE TABLE IF NOT EXISTS|ALTER TABLE)", INFRA_SQL
+ )
+ if re.search(rf"\bapp\.{re.escape(table)}\b", block)
+ ]
+ self.assertTrue(
+ any(
+ re.search(rf"\b{re.escape(column)}\b", block)
+ for block in table_mentions
+ ),
+ msg=f"infra SQL missing column: {qualified_name}",
+ )
+
+ for qualified_name in contract.policies:
+ _, table, policy = qualified_name.split(".")
+ pattern = rf"CREATE POLICY\s+{re.escape(policy)}\s+ON\s+app\.{re.escape(table)}\b"
+ self.assertRegex(
+ INFRA_SQL,
+ pattern,
+ msg=f"infra SQL missing policy: {qualified_name}",
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/api/app/test_session_progress.py b/apps/api/app/test_session_progress.py
new file mode 100644
index 0000000..95577f6
--- /dev/null
+++ b/apps/api/app/test_session_progress.py
@@ -0,0 +1,80 @@
+"""P2 단계 누적 게이지 파생(build_session_progress) 테스트."""
+
+from __future__ import annotations
+
+import unittest
+
+from .services import state_machine
+from .session_read_model import build_session_progress
+
+
+class SessionProgressTest(unittest.TestCase):
+ def test_initial_state_gauges(self) -> None:
+ state = state_machine.SessionState()
+ progress = build_session_progress(state)
+ self.assertEqual([s.stage for s in progress.stages], ["라포", "탐색", "개입", "정리"])
+ self.assertEqual(progress.stages[0].percent, 0)
+ self.assertFalse(progress.stages[0].achieved)
+ self.assertEqual(progress.stages[1].percent, 0)
+ self.assertEqual(progress.rapport_percent, 0)
+ self.assertEqual(progress.rapport_delta_percent, 0)
+
+ def test_current_stage_gauge_scales_with_rapport(self) -> None:
+ state = state_machine.SessionState(rapport_credit=0.15)
+ progress = build_session_progress(state)
+ # 라포 전이 임계 0.30 → 0.15는 절반 = 약 50 (99 스케일 반올림 50)
+ self.assertAlmostEqual(progress.stages[0].percent, 50, delta=2)
+ self.assertFalse(progress.stages[0].achieved)
+
+ def test_current_stage_gauge_caps_at_99_until_transition(self) -> None:
+ state = state_machine.SessionState(rapport_credit=0.9)
+ progress = build_session_progress(state)
+ self.assertEqual(progress.stages[0].percent, 99)
+
+ def test_passed_stage_is_100_and_achieved(self) -> None:
+ state = state_machine.SessionState(
+ stage=state_machine.Stage.EXPLORE,
+ rapport_credit=0.32,
+ )
+ progress = build_session_progress(state)
+ self.assertEqual(progress.stages[0].percent, 100)
+ self.assertTrue(progress.stages[0].achieved)
+ # 탐색 임계 0.45 → 0.32/0.45*99 ≈ 70
+ self.assertAlmostEqual(progress.stages[1].percent, 70, delta=2)
+ self.assertEqual(progress.stages[2].percent, 0)
+
+ def test_close_stage_entry_is_100(self) -> None:
+ state = state_machine.SessionState(
+ stage=state_machine.Stage.CLOSE,
+ rapport_credit=0.6,
+ )
+ progress = build_session_progress(state)
+ self.assertTrue(all(s.percent == 100 for s in progress.stages))
+ self.assertTrue(progress.stages[3].achieved)
+
+ def test_goal_flag_and_delta_from_carry(self) -> None:
+ state = state_machine.SessionState(rapport_credit=0.33)
+ progress = build_session_progress(
+ state,
+ prev_rapport_credit=0.11,
+ goal_stages=["라포", "탐색"],
+ )
+ self.assertTrue(progress.stages[0].is_goal)
+ self.assertTrue(progress.stages[1].is_goal)
+ self.assertFalse(progress.stages[2].is_goal)
+ # 0.33/0.55=60%, 0.11/0.55=20% → 이번 회기 +40%p
+ self.assertEqual(progress.rapport_percent, 60)
+ self.assertEqual(progress.rapport_delta_percent, 40)
+
+ def test_detail_metrics_are_percentages(self) -> None:
+ state = state_machine.SessionState(
+ resistance=0.65,
+ effective_openness=0.42,
+ )
+ progress = build_session_progress(state)
+ self.assertEqual(progress.resistance_percent, 65)
+ self.assertEqual(progress.openness_percent, 42)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/api/app/test_session_time_and_goals.py b/apps/api/app/test_session_time_and_goals.py
new file mode 100644
index 0000000..a2f5b08
--- /dev/null
+++ b/apps/api/app/test_session_time_and_goals.py
@@ -0,0 +1,240 @@
+"""시간 기반 회기 종료(P1) + 회기 목표 선택 계약 테스트.
+
+2026-07-13 한신대 회의: 세션 종료를 4단계 완수가 아니라 시간(기본 60분)이 결정하고,
+회기 시작 전 학습자가 이번 회기 목표 단계를 2개 수준으로 선택한다.
+"""
+
+from __future__ import annotations
+
+import time
+import unittest
+from types import SimpleNamespace
+from unittest.mock import AsyncMock, patch
+
+from fastapi import HTTPException
+
+from .config import settings
+from .deps import Principal, Role
+from .routes import sessions
+from .services import memory, persona as persona_service, state_machine
+from .store import InProcSession, store
+
+
+def _principal() -> Principal:
+ return Principal(
+ user_id="00000000-0000-0000-0000-000000000102",
+ role=Role.LEARNER,
+ cohort_ids=[],
+ email="time-goal-test@hs.ac.kr",
+ display_name="Time Goal Test",
+ consent_at=1.0,
+ profile_completed_at=1.0,
+ )
+
+
+def _session(principal: Principal, *, created_at: float | None = None) -> InProcSession:
+ card = persona_service.P1
+ sess = InProcSession(
+ session_id="time-goal-session",
+ case_id="time-goal-case",
+ learner_id=principal.user_id,
+ persona_code=card.code,
+ theory_mode="humanistic",
+ persona=card,
+ state=state_machine.SessionState(
+ resistance=card.base_resistance(),
+ ideation_stage=card.ideation_baseline(),
+ ),
+ )
+ if created_at is not None:
+ sess.created_at = created_at
+ store.put(sess)
+ return sess
+
+
+class SessionGoalContractTest(unittest.TestCase):
+ def test_goal_stages_are_deduped(self) -> None:
+ req = sessions.SessionStartRequest(
+ persona_code="P1",
+ goal_stages=["라포", "라포"],
+ )
+ self.assertEqual(req.goal_stages, ["라포"])
+
+ def test_goal_stages_allow_up_to_four(self) -> None:
+ req = sessions.SessionStartRequest(
+ persona_code="P1",
+ goal_stages=["라포", "탐색", "개입", "정리"],
+ )
+ self.assertEqual(req.goal_stages, ["라포", "탐색", "개입", "정리"])
+
+ def test_goal_stages_reject_more_than_four_items(self) -> None:
+ with self.assertRaises(Exception):
+ sessions.SessionStartRequest(
+ persona_code="P1",
+ goal_stages=["라포", "탐색", "개입", "정리", "라포"],
+ )
+
+ def test_goal_stages_reject_unknown_stage_label(self) -> None:
+ with self.assertRaises(Exception):
+ sessions.SessionStartRequest(
+ persona_code="P1",
+ goal_stages=["존재하지않는단계"],
+ )
+
+ def test_goal_stages_default_empty_for_legacy_clients(self) -> None:
+ req = sessions.SessionStartRequest(persona_code="P1")
+ self.assertEqual(req.goal_stages, [])
+
+
+class SessionTimeLimitTest(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self) -> None:
+ store._sessions.clear()
+ sessions._RECALL_CACHE.clear()
+ sessions._KB_CUES_CACHE.clear()
+
+ async def asyncTearDown(self) -> None:
+ store._sessions.clear()
+ sessions._RECALL_CACHE.clear()
+ sessions._KB_CUES_CACHE.clear()
+
+ def test_session_context_cache_has_one_owner_and_defensive_reads(self) -> None:
+ session_id = "cache-owner-session"
+ sessions._RECALL_CACHE[session_id] = memory.RecallContext(recall_summary="기억")
+ sessions._KB_CUES_CACHE[session_id] = ["단서"]
+
+ cues = sessions.cached_kb_cues(session_id)
+ cues.append("외부 변경")
+ self.assertEqual(sessions.cached_kb_cues(session_id), ["단서"])
+
+ sessions.invalidate_session_context_cache(session_id)
+ self.assertNotIn(session_id, sessions._RECALL_CACHE)
+ self.assertEqual(sessions.cached_kb_cues(session_id), [])
+
+ def test_session_time_over_false_within_limit(self) -> None:
+ principal = _principal()
+ sess = _session(principal, created_at=time.time() - 30 * 60)
+ self.assertFalse(sessions.session_time_over(sess))
+
+ def test_session_time_over_false_within_overtime_grace(self) -> None:
+ """제한(60분) 초과 직후에는 마무리 유예 안이라 턴이 계속 허용된다."""
+ principal = _principal()
+ limit_minutes = settings.session_duration_minutes
+ sess = _session(principal, created_at=time.time() - (limit_minutes + 2) * 60)
+ self.assertFalse(sessions.session_time_over(sess))
+
+ def test_session_time_over_true_after_grace(self) -> None:
+ principal = _principal()
+ over_minutes = (
+ settings.session_duration_minutes
+ + settings.session_overtime_grace_minutes
+ + 1
+ )
+ sess = _session(principal, created_at=time.time() - over_minutes * 60)
+ self.assertTrue(sessions.session_time_over(sess))
+
+ async def test_prepare_turn_context_rejects_after_grace(self) -> None:
+ principal = _principal()
+ over_minutes = (
+ settings.session_duration_minutes
+ + settings.session_overtime_grace_minutes
+ + 1
+ )
+ sess = _session(principal, created_at=time.time() - over_minutes * 60)
+ with patch.object(
+ sessions,
+ "ensure_recall_context",
+ AsyncMock(return_value=memory.RecallContext()),
+ ):
+ with self.assertRaises(HTTPException) as caught:
+ await sessions._prepare_turn_context(
+ session_id=sess.session_id,
+ sess=sess,
+ learner_text="오늘 이야기 나눠주셔서 감사해요.",
+ )
+ self.assertEqual(caught.exception.status_code, 409)
+ self.assertEqual(caught.exception.detail, "session_time_over")
+
+
+class SessionStartGoalPersistenceTest(unittest.IsolatedAsyncioTestCase):
+ async def asyncSetUp(self) -> None:
+ store._sessions.clear()
+ sessions._RECALL_CACHE.clear()
+
+ async def asyncTearDown(self) -> None:
+ store._sessions.clear()
+ sessions._RECALL_CACHE.clear()
+
+ async def test_start_session_passes_goals_and_returns_time_contract(self) -> None:
+ principal = _principal()
+ card = persona_service.P1
+ catalog_persona = SimpleNamespace(
+ card=card,
+ persona_id="00000000-0000-0000-0000-0000000000a1",
+ version=1,
+ degraded=False,
+ )
+ captured: dict[str, object] = {}
+
+ async def fake_create_session(**kwargs):
+ captured.update(kwargs)
+ return InProcSession(
+ session_id="goal-session",
+ case_id="goal-case",
+ learner_id=principal.user_id,
+ persona_code=card.code,
+ theory_mode=kwargs["theory_mode"],
+ persona=card,
+ state=kwargs["state"],
+ session_no=kwargs["session_no"],
+ prev_rapport_credit=kwargs["carry_rapport"],
+ goal_stages=list(kwargs["goal_stages"]),
+ )
+
+ def close_background(coro):
+ coro.close()
+ return None
+
+ with (
+ patch.object(
+ sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
+ ),
+ patch.object(
+ sessions.session_persistence,
+ "get_case_context",
+ AsyncMock(return_value=None),
+ ),
+ patch.object(
+ sessions,
+ "_build_seed_recall",
+ AsyncMock(return_value=memory.RecallContext()),
+ ),
+ patch.object(
+ sessions.session_persistence,
+ "create_session",
+ fake_create_session,
+ ),
+ patch.object(sessions.asyncio, "create_task", close_background),
+ ):
+ response = await sessions.start_session(
+ sessions.SessionStartRequest(
+ persona_code=card.code,
+ goal_stages=["라포", "탐색"],
+ ),
+ principal,
+ )
+
+ self.assertEqual(captured["goal_stages"], ["라포", "탐색"])
+ self.assertEqual(response.goal_stages, ["라포", "탐색"])
+ self.assertEqual(
+ response.duration_limit_seconds,
+ settings.session_duration_minutes * 60,
+ )
+ self.assertEqual(
+ response.warning_before_end_seconds,
+ settings.session_warning_minutes * 60,
+ )
+ self.assertTrue(response.started_at)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py
index c181e1e..d191b14 100644
--- a/apps/api/app/test_session_turn_persistence.py
+++ b/apps/api/app/test_session_turn_persistence.py
@@ -13,10 +13,18 @@ from .config import settings
from . import session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role
-from .engine_client import EngineError
+from .engine_client import EngineError, GenerateResponse
from .routes import sessions
from .routes import voice as voice_routes
-from .services import guardrail, live_coach, memory, orchestrator, persona as persona_service, rag, state_machine
+from .services import (
+ guardrail,
+ live_coach,
+ memory,
+ orchestrator,
+ persona as persona_service,
+ rag,
+ state_machine,
+)
from .services.voice import TTSChunk, TranscriptResult, VoicePreset
from .store import InProcSession, TurnRecord, store
@@ -88,22 +96,32 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
store._sessions.clear()
sessions._RECALL_CACHE.clear()
sessions._KB_CUES_CACHE.clear()
+ session_persistence._LIVE_COACH_EVENT_CACHE.clear()
async def asyncTearDown(self) -> None:
store._sessions.clear()
sessions._RECALL_CACHE.clear()
sessions._KB_CUES_CACHE.clear()
+ session_persistence._LIVE_COACH_EVENT_CACHE.clear()
- async def test_end_session_does_not_reschedule_evaluation_for_already_ended_session(self) -> None:
+ async def test_end_session_does_not_reschedule_evaluation_for_already_ended_session(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
sess.ended = True
sess.ended_at = 1_000.0
with (
- patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=sess)),
- patch.object(sessions, "_end_persisted_session", AsyncMock(return_value=None)),
- patch.object(sessions, "_schedule_session_evaluation") as schedule_session_evaluation,
+ patch.object(
+ sessions, "_load_session_or_404", AsyncMock(return_value=sess)
+ ),
+ patch.object(
+ sessions, "_end_persisted_session", AsyncMock(return_value=None)
+ ),
+ patch.object(
+ sessions, "_schedule_session_evaluation"
+ ) as schedule_session_evaluation,
):
response = await sessions.end_session(sess.session_id, principal)
@@ -151,10 +169,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
provider_events=[{"type": "sigh", "confidence": 0.82}],
)
- with patch.object(session_persistence, "get_pool", return_value=object()), patch.object(
- session_persistence,
- "acquire",
- return_value=FakeAcquire(conn),
+ with (
+ patch.object(session_persistence, "get_pool", return_value=object()),
+ patch.object(
+ session_persistence,
+ "acquire",
+ return_value=FakeAcquire(conn),
+ ),
):
ok = await session_persistence.append_turn(
session_id="turn-persistence-session",
@@ -169,7 +190,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(conn.insert_args[17], list(turn.visible_to))
self.assertEqual(turn.turn_id, "00000000-0000-0000-0000-000000009999")
- async def test_generate_turn_engine_failure_does_not_append_learner_turn(self) -> None:
+ async def test_generate_turn_engine_failure_does_not_append_learner_turn(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
@@ -296,11 +319,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
principal,
)
- self.assertEqual(response.client_reply, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.")
+ self.assertEqual(
+ response.client_reply,
+ "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.",
+ )
self.assertEqual(len(sess.turns), 2)
learner_turn, client_turn = sess.turns
self.assertIsNone(learner_turn.llm_provider)
- self.assertEqual(client_turn.text, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.")
+ self.assertEqual(
+ client_turn.text, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요."
+ )
self.assertNotIn("김서연", client_turn.text_masked)
self.assertNotIn("한신대학교", client_turn.text_masked)
self.assertNotIn("상담심리학과", client_turn.text_masked)
@@ -312,7 +340,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(client_turn.tokens_out, 23)
self.assertEqual(client_turn.cost_usd, 0.012345)
- async def test_generate_real_crisis_stops_before_engine_and_returns_109_resource(self) -> None:
+ async def test_generate_real_crisis_stops_before_engine_and_returns_109_resource(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
@@ -349,7 +379,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
result = orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
- effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
+ effective_openness=ctx.state_after.effective_openness
+ if ctx.state_after
+ else 0.0,
client_reply=None,
safety_flagged=True,
state_after=ctx.state_after or sess.state,
@@ -368,10 +400,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def __aenter__(self) -> FakeConn:
return FakeConn()
- async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
+ async def __aexit__(
+ self, exc_type: object, exc: object, tb: object
+ ) -> None:
return None
- with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()) as acquire:
+ with patch.object(
+ turn_runtime.db, "acquire", return_value=FakeAcquire()
+ ) as acquire:
await turn_runtime.record_safety_event(sess, ctx, result)
acquire.assert_called_once_with(ai_context=True)
@@ -394,7 +430,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(detail["crisis_resource"]["number"], "109")
self.assertEqual(detail["alert_status"], "teacher_dashboard")
- async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(self) -> None:
+ async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
ctx = orchestrator.prepare_turn(
@@ -408,7 +446,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
result = orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
- effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
+ effective_openness=ctx.state_after.effective_openness
+ if ctx.state_after
+ else 0.0,
client_reply=None,
safety_flagged=True,
state_after=ctx.state_after or sess.state,
@@ -425,7 +465,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def __aenter__(self) -> FakeConn:
return FakeConn()
- async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
+ async def __aexit__(
+ self, exc_type: object, exc: object, tb: object
+ ) -> None:
return None
previous_environment = settings.environment
@@ -480,17 +522,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(client_turn.tokens_out, 37)
self.assertEqual(client_turn.cost_usd, 0.023456)
- async def test_stream_real_crisis_stops_before_engine_and_persists_learner_only(self) -> None:
+ async def test_stream_real_crisis_stops_before_engine_and_persists_learner_only(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
def should_not_stream(*args, **kwargs):
- raise AssertionError("stream engine must not be called for learner_real crisis")
+ raise AssertionError(
+ "stream engine must not be called for learner_real crisis"
+ )
with (
patch.object(sessions.engine_client, "stream", should_not_stream),
- patch.object(turn_runtime, "record_completed_turn", new_callable=AsyncMock) as completed_turn,
- patch.object(turn_runtime, "record_safety_event", new_callable=AsyncMock) as safety_event,
+ patch.object(
+ turn_runtime, "record_completed_turn", new_callable=AsyncMock
+ ) as completed_turn,
+ patch.object(
+ turn_runtime, "record_safety_event", new_callable=AsyncMock
+ ) as safety_event,
):
response = await sessions.stream_turn(
sess.session_id,
@@ -511,7 +561,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(saved_result.conversation_stopped)
self.assertEqual(saved_result.crisis_resource["number"], "109")
- async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None:
+ async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
@@ -543,10 +595,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
"appropriateness_note": f"응답 반영: {client_reply}",
}
- with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object(
- sessions.evaluator,
- "make_eval_hook",
- return_value=fake_eval_hook,
+ with (
+ patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
+ patch.object(
+ sessions.evaluator,
+ "make_eval_hook",
+ return_value=fake_eval_hook,
+ ),
):
response = await sessions.stream_turn(
sess.session_id,
@@ -560,10 +615,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(learner_turn.speaker, "counselor")
self.assertIsNotNone(learner_turn.evaluation)
self.assertEqual(learner_turn.evaluation["appropriateness"], "pos")
- self.assertIn("조금 말해볼게요", learner_turn.evaluation["appropriateness_note"])
+ self.assertIn(
+ "조금 말해볼게요", learner_turn.evaluation["appropriateness_note"]
+ )
self.assertIsNone(client_turn.evaluation)
- async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(self) -> None:
+ async def test_stream_turn_surfaces_fast_loop_evaluation_failure_on_review(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
@@ -589,10 +648,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def failing_eval_hook(ctx, client_reply):
raise RuntimeError("김서연 평가 timeout 010-1234-5678")
- with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream), patch.object(
- sessions.evaluator,
- "make_eval_hook",
- return_value=failing_eval_hook,
+ with (
+ patch.object(sessions.orchestrator, "run_turn_stream", successful_stream),
+ patch.object(
+ sessions.evaluator,
+ "make_eval_hook",
+ return_value=failing_eval_hook,
+ ),
):
response = await sessions.stream_turn(
sess.session_id,
@@ -622,13 +684,17 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNotNone(review_turn.note)
assert review_turn.note is not None
self.assertEqual(review_turn.note.title, "턴 직후 평가 실패")
- self.assertIn("fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body)
+ self.assertIn(
+ "fast-loop(턴 직후) 평가를 완료하지 못했습니다", review_turn.note.body
+ )
self.assertIn("AI 평가 재시도가 필요합니다", review_turn.note.body)
self.assertNotIn("RuntimeError", review_turn.note.body)
self.assertNotIn("김서연", review_turn.note.body)
self.assertNotIn("010-1234-5678", review_turn.note.body)
- async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(self) -> None:
+ async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
sess.turns.append(
@@ -638,18 +704,24 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
stage=sess.state.stage.value,
text="그냥 학교는 가야 하는 거 아닐까요?",
text_masked="그냥 학교는 가야 하는 거 아닐까요?",
- evaluation={"appropriateness": "warn", "appropriateness_note": "조언이 빠름"},
+ evaluation={
+ "appropriateness": "warn",
+ "appropriateness_note": "조언이 빠름",
+ },
)
)
- with patch.object(
- sessions,
- "_retrieve_live_coach_grounding",
- AsyncMock(return_value=[]),
- ), patch.object(
- sessions.engine_client,
- "generate",
- AsyncMock(side_effect=EngineError("offline")),
+ with (
+ patch.object(
+ sessions,
+ "_retrieve_live_coach_grounding",
+ AsyncMock(return_value=[]),
+ ),
+ patch.object(
+ sessions.engine_client,
+ "generate",
+ AsyncMock(side_effect=EngineError("offline")),
+ ),
):
response = await sessions.live_coach_turn(
sess.session_id,
@@ -667,7 +739,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("조언", response.title + response.message)
self.assertTrue(response.next_utterance)
self.assertTrue(response.sources)
- self.assertEqual(response.sources[0].source_id, "workbook_0615_case_conceptualization")
+ self.assertEqual(
+ response.sources[0].source_id, "workbook_0615_case_conceptualization"
+ )
history = await sessions.list_live_coach_history(sess.session_id, principal)
self.assertEqual(history.source, "runtime")
self.assertEqual(len(history.events), 1)
@@ -676,11 +750,85 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(history.events[0].suggestion.status, "degraded")
self.assertEqual(history.events[0].suggestion.title, response.title)
self.assertIn("학교", history.events[0].learner_text_excerpt or "")
- session_persistence._LIVE_COACH_EVENT_CACHE[sess.session_id][0]["stage"] = "unknown-stage"
- legacy_history = await sessions.list_live_coach_history(sess.session_id, principal)
+ session_persistence._LIVE_COACH_EVENT_CACHE[sess.session_id][0]["stage"] = (
+ "unknown-stage"
+ )
+ legacy_history = await sessions.list_live_coach_history(
+ sess.session_id, principal
+ )
self.assertIsNone(legacy_history.events[0].stage)
- async def test_live_coach_rag_grounding_preserves_source_pack_metadata(self) -> None:
+ async def test_live_coach_degrades_when_llm_audit_is_unavailable(self) -> None:
+ principal = _principal()
+ sess = _session(principal)
+ sess.turns.append(
+ TurnRecord(
+ turn_seq=1,
+ speaker="counselor",
+ stage=sess.state.stage.value,
+ text="그 마음이 컸겠네요.",
+ text_masked="그 마음이 컸겠네요.",
+ evaluation={
+ "appropriateness": "pos",
+ "appropriateness_note": "감정 반영",
+ },
+ )
+ )
+ engine_response = GenerateResponse(
+ text="",
+ model="fake-live-coach-model",
+ provider="fake-provider",
+ tokens_in=11,
+ tokens_out=7,
+ cost_usd=0.01,
+ structured={
+ "tone": "pos",
+ "focus": "emotion",
+ "title": "엔진 생성 코칭",
+ "message": "감정 반영을 이어가세요.",
+ "next_utterance": "그 마음이 가장 컸던 순간이 언제였나요?",
+ },
+ )
+
+ with (
+ patch.object(
+ sessions,
+ "_retrieve_live_coach_grounding",
+ AsyncMock(return_value=[]),
+ ),
+ patch.object(
+ sessions.engine_client,
+ "generate",
+ AsyncMock(return_value=engine_response),
+ ) as generate,
+ patch.object(
+ session_persistence,
+ "record_llm_call_audit",
+ AsyncMock(return_value=False),
+ ) as audit,
+ ):
+ response = await sessions.live_coach_turn(
+ sess.session_id,
+ sessions.LiveCoachRequest(
+ learner_text="그 마음이 컸겠네요.",
+ client_reply="네, 아무도 몰라주는 것 같았어요.",
+ turn_seq=1,
+ ),
+ principal,
+ )
+
+ generate.assert_awaited_once()
+ audit.assert_awaited_once()
+ self.assertEqual(response.status, "degraded")
+ self.assertNotEqual(response.title, "엔진 생성 코칭")
+ self.assertIn("응답 검증 기록", response.rationale or "")
+ history = await sessions.list_live_coach_history(sess.session_id, principal)
+ self.assertEqual(len(history.events), 1)
+ self.assertEqual(history.events[0].suggestion.status, "degraded")
+
+ async def test_live_coach_rag_grounding_preserves_source_pack_metadata(
+ self,
+ ) -> None:
retrieval = rag.RetrievalResult(
chunks=[
rag.RetrievedChunk(
@@ -706,8 +854,14 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
with (
- patch.object(sessions.db, "acquire", return_value=_AsyncConnContext(object())),
- patch.object(sessions.rag, "retrieve_eval_grounding", AsyncMock(return_value=retrieval)),
+ patch.object(
+ sessions.db, "acquire", return_value=_AsyncConnContext(object())
+ ),
+ patch.object(
+ sessions.rag,
+ "retrieve_eval_grounding",
+ AsyncMock(return_value=retrieval),
+ ),
patch.object(sessions.rag, "log_retrieval", AsyncMock()),
):
grounding = await sessions._retrieve_live_coach_grounding(
@@ -740,19 +894,23 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
detail="live coach event save persistence unavailable; runtime fallback is disabled in prod",
)
- with patch.object(
- sessions,
- "_retrieve_live_coach_grounding",
- AsyncMock(return_value=[]),
- ), patch.object(
- sessions.live_coach,
- "generate_live_coaching",
- AsyncMock(return_value=suggestion),
- ), patch.object(
- session_persistence,
- "save_live_coach_event",
- AsyncMock(side_effect=save_error),
- ) as save_event:
+ with (
+ patch.object(
+ sessions,
+ "_retrieve_live_coach_grounding",
+ AsyncMock(return_value=[]),
+ ),
+ patch.object(
+ sessions.live_coach,
+ "generate_live_coaching",
+ AsyncMock(return_value=suggestion),
+ ),
+ patch.object(
+ session_persistence,
+ "save_live_coach_event",
+ AsyncMock(side_effect=save_error),
+ ) as save_event,
+ ):
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.live_coach_turn(
sess.session_id,
@@ -797,7 +955,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(coded.stage, "라포")
self.assertIsNone(invalid.stage)
- async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(self) -> None:
+ async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(
+ self,
+ ) -> None:
item = live_coach.LiveCoachInput(
session_id="risk-coach-session",
turn_seq=3,
@@ -809,7 +969,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
learner_text="죽고 싶다는 생각이 들 때도 있나요?",
client_reply="가끔 그런 생각이 들어요.",
recent_turns=[],
- evaluation={"appropriateness": "warn", "appropriateness_note": "위험사정 필요"},
+ evaluation={
+ "appropriateness": "warn",
+ "appropriateness_note": "위험사정 필요",
+ },
)
engine = SimpleNamespace(generate=AsyncMock(side_effect=EngineError("offline")))
@@ -818,7 +981,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(suggestion.focus, "risk")
source_ids = [source.source_id for source in suggestion.sources]
self.assertIn("official_suicide_risk_guidelines", source_ids)
- official = next(source for source in suggestion.sources if source.source_id == "official_suicide_risk_guidelines")
+ official = next(
+ source
+ for source in suggestion.sources
+ if source.source_id == "official_suicide_risk_guidelines"
+ )
self.assertEqual(official.source_type, "official_guideline")
self.assertTrue(official.citation)
engine.generate.assert_called_once()
@@ -865,19 +1032,27 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
coro.close()
return None
- with patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)), patch.object(
- sessions.session_persistence,
- "get_case_context",
- AsyncMock(return_value=case_context),
- ), patch.object(
- sessions,
- "_build_seed_recall",
- AsyncMock(return_value=recall),
- ), patch.object(
- sessions.session_persistence,
- "create_session",
- fake_create_session,
- ), patch.object(sessions.asyncio, "create_task", close_background):
+ with (
+ patch.object(
+ sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
+ ),
+ patch.object(
+ sessions.session_persistence,
+ "get_case_context",
+ AsyncMock(return_value=case_context),
+ ),
+ patch.object(
+ sessions,
+ "_build_seed_recall",
+ AsyncMock(return_value=recall),
+ ),
+ patch.object(
+ sessions.session_persistence,
+ "create_session",
+ fake_create_session,
+ ),
+ patch.object(sessions.asyncio, "create_task", close_background),
+ ):
response = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
principal,
@@ -888,7 +1063,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.recall_summary, recall.recall_summary)
self.assertIs(sessions._RECALL_CACHE[response.session_id], recall)
- async def test_next_session_turn_injects_seed_recall_into_engine_messages(self) -> None:
+ async def test_next_session_turn_injects_seed_recall_into_engine_messages(
+ self,
+ ) -> None:
principal = _principal()
card = persona_service.P1
case_context = sessions.session_persistence.CaseContext(
@@ -931,7 +1108,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def __aenter__(self) -> FakeConn:
return self.conn
- async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
+ async def __aexit__(
+ self, exc_type: object, exc: object, tb: object
+ ) -> None:
return None
async def fake_create_session(**kwargs):
@@ -951,19 +1130,28 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
coro.close()
return None
- with patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)), patch.object(
- sessions.session_persistence,
- "get_case_context",
- AsyncMock(return_value=case_context),
- ), patch.object(sessions.db, "get_pool", return_value=object()), patch.object(
- sessions.db,
- "acquire",
- return_value=FakeAcquire(FakeConn()),
- ), patch.object(
- sessions.session_persistence,
- "create_session",
- fake_create_session,
- ), patch.object(sessions.asyncio, "create_task", close_background):
+ with (
+ patch.object(
+ sessions, "get_catalog_persona", AsyncMock(return_value=catalog_persona)
+ ),
+ patch.object(
+ sessions.session_persistence,
+ "get_case_context",
+ AsyncMock(return_value=case_context),
+ ),
+ patch.object(sessions.db, "get_pool", return_value=object()),
+ patch.object(
+ sessions.db,
+ "acquire",
+ return_value=FakeAcquire(FakeConn()),
+ ),
+ patch.object(
+ sessions.session_persistence,
+ "create_session",
+ fake_create_session,
+ ),
+ patch.object(sessions.asyncio, "create_task", close_background),
+ ):
response = await sessions.start_session(
sessions.SessionStartRequest(persona_code=card.code),
principal,
@@ -985,10 +1173,15 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
state_after=ctx.state_after,
)
- with patch.object(sessions, "_load_session_or_404", AsyncMock(return_value=started)), patch.object(
- sessions.orchestrator,
- "run_turn_generate",
- successful_turn,
+ with (
+ patch.object(
+ sessions, "_load_session_or_404", AsyncMock(return_value=started)
+ ),
+ patch.object(
+ sessions.orchestrator,
+ "run_turn_generate",
+ successful_turn,
+ ),
):
await sessions.submit_turn(
response.session_id,
@@ -1006,14 +1199,20 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("김서연", message_blob)
self.assertNotIn("박민수", message_blob)
- async def test_start_session_requires_learner_consent_before_catalog_lookup(self) -> None:
+ 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")),
+ 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(
@@ -1025,14 +1224,20 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(caught.exception.detail, "consent_required")
get_persona.assert_not_awaited()
- async def test_start_session_requires_onboarding_before_consent_and_catalog_lookup(self) -> None:
+ async def test_start_session_requires_onboarding_before_consent_and_catalog_lookup(
+ self,
+ ) -> None:
principal = _principal()
principal.profile_completed_at = None
with patch.object(
sessions,
"get_catalog_persona",
- AsyncMock(side_effect=AssertionError("onboarding gate must run before catalog lookup")),
+ AsyncMock(
+ side_effect=AssertionError(
+ "onboarding gate must run before catalog lookup"
+ )
+ ),
) as get_persona:
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.start_session(
@@ -1121,13 +1326,17 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual([event.event for event in events], ["error"])
self.assertIn("engine unavailable", events[0].data["detail"])
- async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
+ async def test_stream_turn_engine_error_event_does_not_append_partial_turns(
+ self,
+ ) -> None:
principal = _principal()
sess = _session(principal)
async def failing_stream(*args, **kwargs):
yield orchestrator.StreamEvent("token", {"text": "부분 응답"})
- yield orchestrator.StreamEvent("error", {"detail": "engine unavailable: stream"})
+ yield orchestrator.StreamEvent(
+ "error", {"detail": "engine unavailable: stream"}
+ )
with patch.object(sessions.orchestrator, "run_turn_stream", failing_stream):
response = await sessions.stream_turn(
@@ -1147,7 +1356,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
- import json
self.messages.append(json.loads(data))
@@ -1162,10 +1370,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
):
await voice_routes._run_turn_and_speak(
websocket, # type: ignore[arg-type]
- session_id=sess.session_id,
- principal=principal,
- voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
- learner_text="음성 실패 발화",
+ voice_routes.VoiceSessionContext(
+ session_id=sess.session_id,
+ principal=principal,
+ voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
+ ),
+ voice_routes.VoiceTurnInput(learner_text="음성 실패 발화"),
)
self.assertTrue(
@@ -1186,7 +1396,6 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
- import json
self.messages.append(json.loads(data))
@@ -1217,37 +1426,53 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
websocket = FakeWebSocket()
audio = b"\x00\x80" * 1600
- with patch.object(
- voice_routes.voice_service,
- "transcribe",
- AsyncMock(
- return_value=TranscriptResult(
- text="오늘은 좀 힘들었어요.",
- duration=2.0,
- provider_events=[
- {"kind": "voice_activity", "start_ms": 10, "raw_text": "drop"},
- ],
- )
+ with (
+ patch.object(
+ voice_routes.voice_service,
+ "transcribe",
+ AsyncMock(
+ return_value=TranscriptResult(
+ text="오늘은 좀 힘들었어요.",
+ duration=2.0,
+ provider_events=[
+ {
+ "kind": "voice_activity",
+ "start_ms": 10,
+ "raw_text": "drop",
+ },
+ ],
+ )
+ ),
+ ),
+ patch.object(
+ voice_routes.orchestrator,
+ "run_turn_generate",
+ successful_turn,
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "synthesize_stream",
+ fake_synthesize_stream,
),
- ), patch.object(
- voice_routes.orchestrator,
- "run_turn_generate",
- successful_turn,
- ), patch.object(
- voice_routes.voice_service,
- "synthesize_stream",
- fake_synthesize_stream,
):
await voice_routes._handle_utterance(
websocket, # type: ignore[arg-type]
- session_id=sess.session_id,
- principal=principal,
- voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
- audio=audio,
- fmt="webm",
- silence_ms=1234,
- barge_in=True,
- provider_events=[{"type": "sigh", "confidence": 0.82, "text": "drop"}],
+ voice_routes.VoiceSessionContext(
+ session_id=sess.session_id,
+ principal=principal,
+ voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
+ ),
+ voice_routes.VoiceAudioInput(
+ audio=audio,
+ fmt="webm",
+ prosody=voice_routes.VoiceProsody(
+ silence_ms=1234,
+ barge_in=True,
+ provider_events=[
+ {"type": "sigh", "confidence": 0.82, "text": "drop"}
+ ],
+ ),
+ ),
)
self.assertEqual(len(sess.turns), 2)
@@ -1276,7 +1501,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(client_turn.audio_ref)
self.assertEqual(client_turn.provider_events, [])
self.assertEqual(client_turn.llm_provider, "claude_cli")
- self.assertTrue(any(message.get("type") == "tts_end" for message in websocket.messages))
+ self.assertTrue(
+ any(message.get("type") == "tts_end" for message in websocket.messages)
+ )
async def test_review_exposes_voice_nonverbal_events_on_learner_turn(self) -> None:
principal = _principal()
@@ -1332,7 +1559,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
silence_ms=2500,
speech_rate=180.0,
barge_in=True,
- provider_events=[{"event_type": "cry", "category": "paralinguistic"}],
+ provider_events=[
+ {"event_type": "cry", "category": "paralinguistic"}
+ ],
),
]
)
@@ -1347,7 +1576,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(learner_turn.nonverbal[0].label, "침묵")
self.assertEqual(learner_turn.nonverbal[0].detail, "1.2초")
- self.assertEqual([event.kind for event in learner_turn.nonverbal].count("silence"), 1)
+ self.assertEqual(
+ [event.kind for event in learner_turn.nonverbal].count("silence"), 1
+ )
self.assertEqual(learner_turn.nonverbal[1].detail, "분당 420자")
self.assertEqual(learner_turn.nonverbal[4].label, "음성 단서")
self.assertEqual(learner_turn.nonverbal[4].detail, "한숨 감지 · 신뢰도 82%")
@@ -1390,7 +1621,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertGreaterEqual(len(worksheet.sections), 5)
exploration = worksheet.sections[0]
self.assertEqual(exploration.key, "exploration_11")
- complaint = next(item for item in exploration.items if item.key == "presenting_complaint")
+ complaint = next(
+ item for item in exploration.items if item.key == "presenting_complaint"
+ )
self.assertEqual(complaint.confidence, "medium")
self.assertEqual(complaint.evidence[0].turnId, "t2")
self.assertIn("불안", complaint.value or "")
@@ -1509,14 +1742,18 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
AsyncMock(return_value=(review_status, True)),
),
):
- response = await sessions.get_session_review(sess.session_id, teacher_principal)
+ response = await sessions.get_session_review(
+ sess.session_id, teacher_principal
+ )
self.assertIsNotNone(response.teacherReview)
assert response.teacherReview is not None
self.assertEqual(response.caseWorksheet.status, "saved_by_learner")
self.assertEqual(response.teacherReview.worksheetStatus, "changes_requested")
self.assertIn("주호소", response.teacherReview.worksheetNote)
- self.assertEqual(response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z")
+ self.assertEqual(
+ response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z"
+ )
async def test_review_remasks_legacy_session_evaluation_payload(self) -> None:
class FakeKoRecognizer:
@@ -1528,7 +1765,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
):
start = text.find(value)
if start >= 0:
- spans.append(guardrail.PiiEntitySpan(entity_type, start, start + len(value)))
+ spans.append(
+ guardrail.PiiEntitySpan(
+ entity_type, start, start + len(value)
+ )
+ )
return spans
guardrail.set_ko_pii_recognizer(FakeKoRecognizer())
@@ -1668,7 +1909,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertFalse(response.reviewReady)
self.assertTrue(response.degraded)
self.assertEqual(response.supervisorState, "평가 실패")
- self.assertIn("deep-loop 평가 AI 산출물을 표시하지 못했습니다", response.summary)
+ self.assertIn(
+ "deep-loop 평가 AI 산출물을 표시하지 못했습니다", response.summary
+ )
self.assertIn("AI 평가 재시도가 필요합니다", response.summary)
self.assertNotIn("session evaluation timeout after 45s", response.summary)
self.assertEqual(response.rubric, [])
@@ -1743,15 +1986,18 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
limitations=["임상 루브릭 전"],
)
- with patch.object(
- sessions,
- "_load_session_or_404",
- AsyncMock(return_value=sess),
- ) as load_session, patch.object(
- sessions.session_persistence,
- "save_case_worksheet",
- AsyncMock(return_value=True),
- ) as save_worksheet:
+ with (
+ patch.object(
+ sessions,
+ "_load_session_or_404",
+ AsyncMock(return_value=sess),
+ ) as load_session,
+ patch.object(
+ sessions.session_persistence,
+ "save_case_worksheet",
+ AsyncMock(return_value=True),
+ ) as save_worksheet,
+ ):
response = await sessions.save_session_review_worksheet(
sess.session_id,
request,
@@ -1769,7 +2015,9 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(save_kwargs["session_id"], sess.session_id)
self.assertEqual(save_kwargs["learner_id"], principal.user_id)
self.assertEqual(save_kwargs["payload"]["status"], "saved_by_learner")
- self.assertEqual(save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소")
+ self.assertEqual(
+ save_kwargs["payload"]["sections"][0]["items"][0]["value"], "수정한 주호소"
+ )
self.assertEqual(response.status, "saved_by_learner")
self.assertEqual(response.sections[0].items[0].value, "수정한 주호소")
diff --git a/apps/api/app/test_tabular_ingest.py b/apps/api/app/test_tabular_ingest.py
new file mode 100644
index 0000000..c06a133
--- /dev/null
+++ b/apps/api/app/test_tabular_ingest.py
@@ -0,0 +1,99 @@
+"""P4 자유 양식 엑셀/CSV → 텍스트 변환 테스트."""
+
+from __future__ import annotations
+
+import io
+import unittest
+
+from .services.tabular_ingest import (
+ MAX_UPLOAD_BYTES,
+ TabularIngestError,
+ extract_tabular_text,
+)
+
+
+def _xlsx_bytes(rows_by_sheet: dict[str, list[list[object]]]) -> bytes:
+ import openpyxl
+
+ workbook = openpyxl.Workbook()
+ default = workbook.active
+ first = True
+ for sheet_name, rows in rows_by_sheet.items():
+ if first:
+ sheet = default
+ sheet.title = sheet_name
+ first = False
+ else:
+ sheet = workbook.create_sheet(sheet_name)
+ for row in rows:
+ sheet.append(row)
+ buffer = io.BytesIO()
+ workbook.save(buffer)
+ return buffer.getvalue()
+
+
+class TabularIngestTest(unittest.TestCase):
+ def test_xlsx_label_value_rows_become_pairs(self) -> None:
+ data = _xlsx_bytes(
+ {
+ "추고록": [
+ ["이름", "김하늘"],
+ ["주호소", "시험 전 복통과 불안, 성적 하락 후 심화"],
+ [None, None],
+ ["상담 목표", "불안 대처와 자기 이해"],
+ ]
+ }
+ )
+ text = extract_tabular_text(filename="자유양식.xlsx", data=data)
+ self.assertIn("## 시트: 추고록", text)
+ self.assertIn("이름: 김하늘", text)
+ self.assertIn("주호소: 시험 전 복통과 불안, 성적 하락 후 심화", text)
+
+ def test_xlsx_multi_cell_rows_join_with_pipe(self) -> None:
+ data = _xlsx_bytes(
+ {
+ "Sheet1": [
+ ["회차", "날짜", "내용 요약"],
+ [1, "2026-05-02", "첫 면담, 라포 형성 시도했으나 침묵이 길었음"],
+ ]
+ }
+ )
+ text = extract_tabular_text(filename="log.xlsx", data=data)
+ self.assertIn("회차 | 날짜 | 내용 요약", text)
+ self.assertIn("1 | 2026-05-02 | 첫 면담, 라포 형성 시도했으나 침묵이 길었음", text)
+
+ def test_csv_cp949_fallback(self) -> None:
+ csv_text = "라벨,값\n주호소,불안과 무기력이 이어지고 있음\n"
+ data = csv_text.encode("cp949")
+ text = extract_tabular_text(filename="notes.csv", data=data)
+ self.assertIn("주호소: 불안과 무기력이 이어지고 있음", text)
+
+ def test_legacy_xls_rejected_with_guidance(self) -> None:
+ with self.assertRaises(TabularIngestError) as caught:
+ extract_tabular_text(filename="old.xls", data=b"anything")
+ self.assertIn(".xlsx", str(caught.exception))
+
+ def test_unknown_extension_rejected(self) -> None:
+ with self.assertRaises(TabularIngestError):
+ extract_tabular_text(filename="notes.hwp", data=b"1234")
+
+ def test_empty_file_rejected(self) -> None:
+ with self.assertRaises(TabularIngestError):
+ extract_tabular_text(filename="a.xlsx", data=b"")
+
+ def test_oversize_rejected(self) -> None:
+ with self.assertRaises(TabularIngestError):
+ extract_tabular_text(filename="a.xlsx", data=b"0" * (MAX_UPLOAD_BYTES + 1))
+
+ def test_nearly_empty_sheet_rejected(self) -> None:
+ data = _xlsx_bytes({"Sheet1": [["a"]]})
+ with self.assertRaises(TabularIngestError):
+ extract_tabular_text(filename="empty.xlsx", data=data)
+
+ def test_corrupt_xlsx_rejected(self) -> None:
+ with self.assertRaises(TabularIngestError):
+ extract_tabular_text(filename="broken.xlsx", data=b"not a zip at all")
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/apps/api/app/test_voice_ws.py b/apps/api/app/test_voice_ws.py
index de7477e..a5dc857 100644
--- a/apps/api/app/test_voice_ws.py
+++ b/apps/api/app/test_voice_ws.py
@@ -166,7 +166,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99))
- async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(self) -> None:
+ async def test_text_turn_speech_returns_openai_audio_for_owned_persisted_turn(
+ self,
+ ) -> None:
session = SimpleNamespace(
persona=SimpleNamespace(code="P1"),
turns=[
@@ -186,30 +188,37 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
yield TTSChunk(audio=b"mp3-a")
yield TTSChunk(audio=b"mp3-b")
- with patch.object(
- voice_routes,
- "_practice_access_error",
- AsyncMock(return_value=None),
- ), patch.object(
- voice_routes.turn_runtime,
- "load_owned_session",
- AsyncMock(return_value=(session, None)),
- ), patch.object(
- voice_routes,
- "_resolve_session_voice",
- AsyncMock(return_value=VOICE_PRESET),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes.voice_service,
- "tts_provider",
- return_value="openai",
- ), patch.object(
- voice_routes.voice_service,
- "synthesize_stream",
- new=synthesize,
+ with (
+ patch.object(
+ voice_routes,
+ "_practice_access_error",
+ AsyncMock(return_value=None),
+ ),
+ patch.object(
+ voice_routes.turn_runtime,
+ "load_owned_session",
+ AsyncMock(return_value=(session, None)),
+ ),
+ patch.object(
+ voice_routes,
+ "_resolve_session_voice",
+ AsyncMock(return_value=VOICE_PRESET),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "tts_provider",
+ return_value="openai",
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "synthesize_stream",
+ new=synthesize,
+ ),
):
response = await voice_routes.voice_speech(
voice_routes.VoiceSpeechRequest(session_id=SESSION_ID, turn_seq=4),
@@ -223,7 +232,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(response.headers["x-vignette-tts-provider"], "openai")
self.assertEqual(synthesized, [("내담자 응답", VOICE_PRESET)])
- async def test_audio_start_binary_chunks_audio_end_ping_close_contract(self) -> None:
+ async def test_audio_start_binary_chunks_audio_end_ping_close_contract(
+ self,
+ ) -> None:
websocket = FakeWebSocket(
[
_control({"type": "audio_start", "format": "webm"}),
@@ -252,55 +263,66 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
handle_utterance = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal()),
- ), patch.object(
- voice_routes,
- "_bind_session",
- AsyncMock(return_value=self._bind_result()),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes,
- "_handle_utterance",
- handle_utterance,
- ), patch.object(
- voice_routes,
- "_run_turn_and_speak",
- AsyncMock(),
- ), patch.object(
- voice_routes.time,
- "monotonic",
- side_effect=[10.0, 12.0],
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal()),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ AsyncMock(return_value=self._bind_result()),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes,
+ "_handle_utterance",
+ handle_utterance,
+ ),
+ patch.object(
+ voice_routes,
+ "_run_turn_and_speak",
+ AsyncMock(),
+ ),
+ patch.object(
+ voice_routes.time,
+ "monotonic",
+ side_effect=[10.0, 12.0],
+ ),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(websocket.accepted)
self.assertEqual(websocket.close_codes, [1000])
self.assertEqual(
- [(message.get("type"), message.get("state")) for message in websocket.sent_json],
+ [
+ (message.get("type"), message.get("state"))
+ for message in websocket.sent_json
+ ],
[("ready", "idle"), ("state", "listening"), ("pong", None)],
)
handle_utterance.assert_awaited_once()
- kwargs = handle_utterance.await_args.kwargs
- self.assertEqual(kwargs["session_id"], SESSION_ID)
- self.assertEqual(kwargs["principal"].user_id, _principal().user_id)
- self.assertEqual(kwargs["voice_preset"], VOICE_PRESET)
- self.assertEqual(kwargs["audio"], b"chunk-onechunk-two")
- self.assertEqual(kwargs["fmt"], "webm")
- self.assertIsNone(kwargs["sample_rate"])
- self.assertIsNone(kwargs["channels"])
- self.assertIsNone(kwargs["sample_width"])
- self.assertEqual(kwargs["audio_started_at"], 10.0)
- self.assertEqual(kwargs["audio_ended_at"], 12.0)
- self.assertEqual(kwargs["silence_ms"], 450)
- self.assertIs(kwargs["barge_in"], True)
+ context = handle_utterance.await_args.args[1]
+ utterance = handle_utterance.await_args.args[2]
+ self.assertEqual(context.session_id, SESSION_ID)
+ self.assertEqual(context.principal.user_id, _principal().user_id)
+ self.assertEqual(context.voice_preset, VOICE_PRESET)
+ self.assertEqual(utterance.audio, b"chunk-onechunk-two")
+ self.assertEqual(utterance.fmt, "webm")
+ self.assertIsNone(utterance.sample_rate)
+ self.assertIsNone(utterance.channels)
+ self.assertIsNone(utterance.sample_width)
+ self.assertEqual(utterance.audio_started_at, 10.0)
+ self.assertEqual(utterance.audio_ended_at, 12.0)
+ self.assertEqual(utterance.prosody.silence_ms, 450)
+ self.assertIs(utterance.prosody.barge_in, True)
self.assertEqual(
- kwargs["provider_events"],
+ utterance.prosody.provider_events,
[
{
"type": "sigh",
@@ -339,7 +361,15 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
async def test_pcm_control_metadata_flows_to_handle_utterance(self) -> None:
websocket = FakeWebSocket(
[
- _control({"type": "audio_start", "format": "pcm", "sample_rate": 16000, "channels": 1, "sample_width": 2}),
+ _control(
+ {
+ "type": "audio_start",
+ "format": "pcm",
+ "sample_rate": 16000,
+ "channels": 1,
+ "sample_width": 2,
+ }
+ ),
_binary(b"\x00\x00\xff\x7f"),
_control({"type": "audio_end", "format": "pcm"}),
_control({"type": "close"}),
@@ -347,33 +377,40 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
handle_utterance = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal()),
- ), patch.object(
- voice_routes,
- "_bind_session",
- AsyncMock(return_value=self._bind_result()),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes,
- "_handle_utterance",
- handle_utterance,
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal()),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ AsyncMock(return_value=self._bind_result()),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes,
+ "_handle_utterance",
+ handle_utterance,
+ ),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
handle_utterance.assert_awaited_once()
- kwargs = handle_utterance.await_args.kwargs
- self.assertEqual(kwargs["fmt"], "pcm")
- self.assertEqual(kwargs["sample_rate"], 16000)
- self.assertEqual(kwargs["channels"], 1)
- self.assertEqual(kwargs["sample_width"], 2)
+ utterance = handle_utterance.await_args.args[2]
+ self.assertEqual(utterance.fmt, "pcm")
+ self.assertEqual(utterance.sample_rate, 16000)
+ self.assertEqual(utterance.channels, 1)
+ self.assertEqual(utterance.sample_width, 2)
- async def test_text_turn_strips_text_runs_turn_and_ping_close_still_work(self) -> None:
+ async def test_text_turn_strips_text_runs_turn_and_ping_close_still_work(
+ self,
+ ) -> None:
websocket = FakeWebSocket(
[
_control({"type": "text_turn", "text": " I need help practicing. "}),
@@ -384,41 +421,51 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
run_turn = AsyncMock()
handle_utterance = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal()),
- ), patch.object(
- voice_routes,
- "_bind_session",
- AsyncMock(return_value=self._bind_result()),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes,
- "_handle_utterance",
- handle_utterance,
- ), patch.object(
- voice_routes,
- "_run_turn_and_speak",
- run_turn,
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal()),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ AsyncMock(return_value=self._bind_result()),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes,
+ "_handle_utterance",
+ handle_utterance,
+ ),
+ patch.object(
+ voice_routes,
+ "_run_turn_and_speak",
+ run_turn,
+ ),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertEqual(websocket.close_codes, [1000])
self.assertEqual(
- [(message.get("type"), message.get("state")) for message in websocket.sent_json],
+ [
+ (message.get("type"), message.get("state"))
+ for message in websocket.sent_json
+ ],
[("ready", "idle"), ("pong", None)],
)
handle_utterance.assert_not_awaited()
run_turn.assert_awaited_once()
- kwargs = run_turn.await_args.kwargs
- self.assertEqual(kwargs["session_id"], SESSION_ID)
- self.assertEqual(kwargs["principal"].user_id, _principal().user_id)
- self.assertEqual(kwargs["voice_preset"], VOICE_PRESET)
- self.assertEqual(kwargs["learner_text"], "I need help practicing.")
+ context = run_turn.await_args.args[1]
+ turn = run_turn.await_args.args[2]
+ self.assertEqual(context.session_id, SESSION_ID)
+ self.assertEqual(context.principal.user_id, _principal().user_id)
+ self.assertEqual(context.voice_preset, VOICE_PRESET)
+ self.assertEqual(turn.learner_text, "I need help practicing.")
async def test_turn_persistence_failure_uses_structured_voice_error(self) -> None:
websocket = FakeWebSocket(
@@ -437,22 +484,27 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
)
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal()),
- ), patch.object(
- voice_routes,
- "_bind_session",
- AsyncMock(return_value=self._bind_result()),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes,
- "_run_turn_and_speak",
- run_turn,
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal()),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ AsyncMock(return_value=self._bind_result()),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes,
+ "_run_turn_and_speak",
+ run_turn,
+ ),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
@@ -469,7 +521,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(websocket.sent_json[-1], {"type": "state", "state": "idle"})
- async def test_stt_result_waits_for_final_transcript_before_running_turn(self) -> None:
+ async def test_stt_result_waits_for_final_transcript_before_running_turn(
+ self,
+ ) -> None:
websocket = FakeWebSocket(
[
_control(
@@ -486,26 +540,32 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
run_turn = AsyncMock()
handle_utterance = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal()),
- ), patch.object(
- voice_routes,
- "_bind_session",
- AsyncMock(return_value=self._bind_result()),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes,
- "_handle_utterance",
- handle_utterance,
- ), patch.object(
- voice_routes,
- "_run_turn_and_speak",
- run_turn,
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal()),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ AsyncMock(return_value=self._bind_result()),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes,
+ "_handle_utterance",
+ handle_utterance,
+ ),
+ patch.object(
+ voice_routes,
+ "_run_turn_and_speak",
+ run_turn,
+ ),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
@@ -521,7 +581,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
},
websocket.sent_json,
)
- self.assertEqual(websocket.sent_json[-1], {"type": "state", "state": "listening"})
+ self.assertEqual(
+ websocket.sent_json[-1], {"type": "state", "state": "listening"}
+ )
async def test_stt_result_runs_turn_only_after_eot_ready(self) -> None:
websocket = FakeWebSocket(
@@ -549,30 +611,37 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
run_turn = AsyncMock()
handle_utterance = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal()),
- ), patch.object(
- voice_routes,
- "_bind_session",
- AsyncMock(return_value=self._bind_result()),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes,
- "_handle_utterance",
- handle_utterance,
- ), patch.object(
- voice_routes,
- "_run_turn_and_speak",
- run_turn,
- ), patch.object(
- voice_routes.time,
- "monotonic",
- side_effect=[10.0, 12.0],
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal()),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ AsyncMock(return_value=self._bind_result()),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes,
+ "_handle_utterance",
+ handle_utterance,
+ ),
+ patch.object(
+ voice_routes,
+ "_run_turn_and_speak",
+ run_turn,
+ ),
+ patch.object(
+ voice_routes.time,
+ "monotonic",
+ side_effect=[10.0, 12.0],
+ ),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
@@ -598,13 +667,13 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
websocket.sent_json,
)
self.assertIn({"type": "state", "state": "thinking"}, websocket.sent_json)
- kwargs = run_turn.await_args.kwargs
- self.assertEqual(kwargs["learner_text"], "I am done now.")
- self.assertEqual(kwargs["duration_s"], 2.0)
- self.assertEqual(kwargs["silence_ms"], 1300)
- self.assertIs(kwargs["barge_in"], False)
+ turn = run_turn.await_args.args[2]
+ self.assertEqual(turn.learner_text, "I am done now.")
+ self.assertEqual(turn.prosody.duration_s, 2.0)
+ self.assertEqual(turn.prosody.silence_ms, 1300)
+ self.assertIs(turn.prosody.barge_in, False)
self.assertEqual(
- kwargs["provider_events"],
+ turn.prosody.provider_events,
[
{
"type": "speech_final",
@@ -615,7 +684,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
],
)
- async def test_oversize_binary_audio_reports_error_and_drops_utterance(self) -> None:
+ async def test_oversize_binary_audio_reports_error_and_drops_utterance(
+ self,
+ ) -> None:
websocket = FakeWebSocket(
[
_control({"type": "audio_start", "format": "webm"}),
@@ -626,30 +697,37 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
handle_utterance = AsyncMock()
run_turn = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal()),
- ), patch.object(
- voice_routes,
- "_bind_session",
- AsyncMock(return_value=self._bind_result()),
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ), patch.object(
- voice_routes,
- "_handle_utterance",
- handle_utterance,
- ), patch.object(
- voice_routes,
- "_run_turn_and_speak",
- run_turn,
- ), patch.object(
- voice_routes,
- "_MAX_AUDIO_BYTES",
- 4,
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal()),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ AsyncMock(return_value=self._bind_result()),
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ),
+ patch.object(
+ voice_routes,
+ "_handle_utterance",
+ handle_utterance,
+ ),
+ patch.object(
+ voice_routes,
+ "_run_turn_and_speak",
+ run_turn,
+ ),
+ patch.object(
+ voice_routes,
+ "_MAX_AUDIO_BYTES",
+ 4,
+ ),
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
@@ -664,48 +742,62 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
handle_utterance.assert_not_awaited()
run_turn.assert_not_awaited()
- async def test_unauthenticated_client_closes_before_session_or_voice_checks(self) -> None:
+ async def test_unauthenticated_client_closes_before_session_or_voice_checks(
+ self,
+ ) -> None:
websocket = FakeWebSocket()
bind_session = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=None),
- ), patch.object(
- voice_routes,
- "_bind_session",
- bind_session,
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ) as is_available:
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=None),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ bind_session,
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ) as is_available,
+ ):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(websocket.accepted)
self.assertEqual(websocket.close_codes, [voice_routes.WS_CLOSE_UNAUTHORIZED])
- self.assertEqual(websocket.sent_json, [{"type": "error", "detail": "not authenticated"}])
+ self.assertEqual(
+ websocket.sent_json, [{"type": "error", "detail": "not authenticated"}]
+ )
bind_session.assert_not_awaited()
is_available.assert_not_called()
- async def test_non_learner_client_closes_before_session_or_voice_checks(self) -> None:
+ async def test_non_learner_client_closes_before_session_or_voice_checks(
+ self,
+ ) -> None:
websocket = FakeWebSocket()
bind_session = AsyncMock()
- with patch.object(
- voice_routes,
- "_principal_from_websocket",
- AsyncMock(return_value=_principal(Role.TEACHER)),
- ), patch.object(
- voice_routes,
- "_bind_session",
- bind_session,
- ), patch.object(
- voice_routes.voice_service,
- "is_available",
- return_value=True,
- ) as is_available:
+ with (
+ patch.object(
+ voice_routes,
+ "_principal_from_websocket",
+ AsyncMock(return_value=_principal(Role.TEACHER)),
+ ),
+ patch.object(
+ voice_routes,
+ "_bind_session",
+ bind_session,
+ ),
+ patch.object(
+ voice_routes.voice_service,
+ "is_available",
+ return_value=True,
+ ) as is_available,
+ ):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(websocket.accepted)
@@ -733,14 +825,17 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
)
get_voice_map = AsyncMock(return_value=voice_map)
- with patch.object(
- voice_routes,
- "_load_voice_session",
- AsyncMock(return_value=(sess, None)),
- ), patch.object(
- voice_routes,
- "get_session_voice_map",
- get_voice_map,
+ with (
+ patch.object(
+ voice_routes,
+ "_load_voice_session",
+ AsyncMock(return_value=(sess, None)),
+ ),
+ patch.object(
+ voice_routes,
+ "get_session_voice_map",
+ get_voice_map,
+ ),
):
session_id, voice, err, meta = await voice_routes._bind_session(
websocket, _principal()
@@ -749,31 +844,40 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(session_id, SESSION_ID)
self.assertIsNone(err)
self.assertEqual(meta["persona_catalog_source"], "session")
- self.assertEqual(voice, VoicePreset(
- preset="calm-adult-male",
- openai_voice="onyx",
- rate=1.08,
- instructions="Low, guarded delivery.",
- ))
+ self.assertEqual(
+ voice,
+ VoicePreset(
+ preset="calm-adult-male",
+ openai_voice="onyx",
+ rate=1.08,
+ instructions="Low, guarded delivery.",
+ ),
+ )
get_voice_map.assert_awaited_once_with(SESSION_ID)
- async def test_bind_session_rejects_existing_session_without_onboarding(self) -> None:
+ async def test_bind_session_rejects_existing_session_without_onboarding(
+ self,
+ ) -> None:
websocket = FakeWebSocket()
websocket.query_params = {"session_id": SESSION_ID}
load_voice_session = AsyncMock()
- with patch.object(
- voice_routes,
- "user_onboarding_complete",
- AsyncMock(return_value=False),
- ), patch.object(
- voice_routes,
- "user_has_consent",
- AsyncMock(return_value=True),
- ), patch.object(
- voice_routes,
- "_load_voice_session",
- load_voice_session,
+ with (
+ patch.object(
+ voice_routes,
+ "user_onboarding_complete",
+ AsyncMock(return_value=False),
+ ),
+ patch.object(
+ voice_routes,
+ "user_has_consent",
+ AsyncMock(return_value=True),
+ ),
+ patch.object(
+ voice_routes,
+ "_load_voice_session",
+ load_voice_session,
+ ),
):
session_id, voice, err, meta = await voice_routes._bind_session(
websocket,
@@ -791,14 +895,17 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
websocket.query_params = {"session_id": SESSION_ID}
load_voice_session = AsyncMock()
- with patch.object(
- voice_routes,
- "user_has_consent",
- AsyncMock(return_value=False),
- ), patch.object(
- voice_routes,
- "_load_voice_session",
- load_voice_session,
+ with (
+ patch.object(
+ voice_routes,
+ "user_has_consent",
+ AsyncMock(return_value=False),
+ ),
+ patch.object(
+ voice_routes,
+ "_load_voice_session",
+ load_voice_session,
+ ),
):
session_id, voice, err, meta = await voice_routes._bind_session(
websocket,
@@ -817,14 +924,17 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
sess = SimpleNamespace(persona=SimpleNamespace(code="P2"))
get_voice_map = AsyncMock()
- with patch.object(
- voice_routes,
- "_load_voice_session",
- AsyncMock(return_value=(sess, None)),
- ), patch.object(
- voice_routes,
- "get_session_voice_map",
- get_voice_map,
+ with (
+ patch.object(
+ voice_routes,
+ "_load_voice_session",
+ AsyncMock(return_value=(sess, None)),
+ ),
+ patch.object(
+ voice_routes,
+ "get_session_voice_map",
+ get_voice_map,
+ ),
):
session_id, voice, err, _ = await voice_routes._bind_session(
websocket, _principal()
@@ -836,7 +946,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(voice.openai_voice, "coral")
get_voice_map.assert_not_awaited()
- async def test_catalog_voice_map_is_used_for_dev_persona_binding_helper(self) -> None:
+ async def test_catalog_voice_map_is_used_for_dev_persona_binding_helper(
+ self,
+ ) -> None:
voice_map = PersonaVoiceMap(
provider="openai",
voice_id="verse",
diff --git a/apps/api/app/turn_runtime.py b/apps/api/app/turn_runtime.py
index 9aba28e..b613a54 100644
--- a/apps/api/app/turn_runtime.py
+++ b/apps/api/app/turn_runtime.py
@@ -19,8 +19,12 @@ from .store import InProcSession, TurnRecord, store
logger = logging.getLogger(__name__)
-_LIVE_COACH_RECHARGE_MIN_RAPPORT = 0.35
-_LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN = 0.02
+_LIVE_COACH_RECHARGE_MIN_RAPPORT = 0.15
+_LIVE_COACH_RECHARGE_NEUTRAL_MIN_RAPPORT = 0.35
+_LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN = 0.01
+# 페이싱 충전: 평가 신호와 무관하게 N턴마다 1개. 코칭이 가장 필요한(잘 못 풀리는)
+# 학습자가 3개 소진 후 영영 코칭을 못 받는 순감 구조를 막는다(2026-07-13 회의 후속).
+_LIVE_COACH_PACING_RECHARGE_EVERY_TURNS = 6
class SessionAccessError(str, Enum):
@@ -187,25 +191,42 @@ def should_recharge_live_coach_credit(
before: state_machine.SessionState,
after: state_machine.SessionState,
) -> tuple[bool, str]:
- """Good-score recharge gate based on stored evaluator/state-machine evidence."""
- if not isinstance(evaluation, dict):
- return False, ""
- if evaluation.get("appropriateness") != "pos":
- return False, ""
- try:
- rapport = float(evaluation.get("rapport_signal") or 0)
- except (TypeError, ValueError):
- rapport = 0.0
- if rapport < _LIVE_COACH_RECHARGE_MIN_RAPPORT:
- return False, ""
+ """좋은 발화 충전 + 진행 페이싱 충전 게이트.
+ 기존 3중 AND(appropriateness==pos + rapport>=0.35 + 단계전환/개방도+0.02)는
+ 실사용에서 충전이 사실상 불가능해 코칭이 3턴 만에 죽었다. 완화된 성과 충전에
+ 페이싱 충전을 더해, 잘하는 학습자는 빨리·모두가 주기적으로 충전받게 한다.
+ """
+ appropriateness = evaluation.get("appropriateness") if isinstance(evaluation, dict) else None
+ rapport = 0.0
+ if isinstance(evaluation, dict):
+ try:
+ rapport = float(evaluation.get("rapport_signal") or 0)
+ except (TypeError, ValueError):
+ rapport = 0.0
openness_gain = float(after.effective_openness or 0) - float(before.effective_openness or 0)
stage_changed = after.stage != before.stage
- if not stage_changed and openness_gain < _LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN:
- return False, ""
- if stage_changed:
- return True, "좋은 발화로 내담자 단계가 열려 코칭 기회 1개를 충전했습니다."
- return True, "좋은 발화 뒤 내담자 개방도가 올라 코칭 기회 1개를 충전했습니다."
+
+ if appropriateness == "pos" and rapport >= _LIVE_COACH_RECHARGE_MIN_RAPPORT:
+ if stage_changed:
+ return True, "좋은 발화로 내담자 단계가 열려 코칭 기회 1개를 충전했습니다."
+ if openness_gain >= _LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN:
+ return True, "좋은 발화 뒤 내담자 개방도가 올라 코칭 기회 1개를 충전했습니다."
+ if (
+ appropriateness == "neutral"
+ and rapport >= _LIVE_COACH_RECHARGE_NEUTRAL_MIN_RAPPORT
+ and openness_gain > 0
+ ):
+ return True, "안정적인 라포 신호로 코칭 기회 1개를 충전했습니다."
+
+ # 페이싱 충전 — 평가 실패(evaluation=None)여도 회기 진행 자체로 충전된다.
+ turn_seq = int(after.turn_seq or 0)
+ if turn_seq > 0 and turn_seq % _LIVE_COACH_PACING_RECHARGE_EVERY_TURNS == 0:
+ return (
+ True,
+ f"회기가 {_LIVE_COACH_PACING_RECHARGE_EVERY_TURNS}턴 진행되어 코칭 기회 1개를 충전했습니다.",
+ )
+ return False, ""
async def maybe_recharge_live_coach_credit(
diff --git a/apps/api/engine_gateway/gateway.py b/apps/api/engine_gateway/gateway.py
index 9239791..018ecf8 100644
--- a/apps/api/engine_gateway/gateway.py
+++ b/apps/api/engine_gateway/gateway.py
@@ -42,6 +42,9 @@ DEFAULT_BUDGET = float(os.environ.get("SESSION_BUDGET_USD", "5.0"))
READY_TTL_SECONDS = float(os.environ.get("ENGINE_READY_TTL_SECONDS", "30"))
READY_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_READY_TIMEOUT_SECONDS", "20"))
READY_BUDGET_USD = float(os.environ.get("ENGINE_READY_BUDGET_USD", "0.5"))
+# 단발 생성(/v1/generate) 턴 타임아웃 — 페르소나 초안 생성 같은 대형 구조화 출력은
+# 120초를 넘길 수 있어 설정 가능하게 한다(2026-07-15). 호출부(app ENGINE_TIMEOUT)와 정합 필요.
+GENERATE_TURN_TIMEOUT_SECONDS = float(os.environ.get("ENGINE_GENERATE_TIMEOUT_SECONDS", "300"))
GATEWAY_PROVIDER = "claude_cli"
GATEWAY_FALLBACK_MODEL_NAME = "claude-opus-4-8"
@@ -424,7 +427,7 @@ async def v1_generate(req: GwGenerateReq):
s, ephemeral = await _resolve_session(req, system_prompt)
try:
- result = await s.turn(prompt_parts.user_payload, timeout=120.0)
+ result = await s.turn(prompt_parts.user_payload, timeout=GENERATE_TURN_TIMEOUT_SECONDS)
finally:
if ephemeral:
await s.close()
diff --git a/apps/api/engine_gateway/test_gateway_model.py b/apps/api/engine_gateway/test_gateway_model.py
index 15ba19c..40af947 100644
--- a/apps/api/engine_gateway/test_gateway_model.py
+++ b/apps/api/engine_gateway/test_gateway_model.py
@@ -581,7 +581,7 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(validated.text, "reused response")
self.assertEqual(validated.provider, "claude_cli")
self.assertEqual(validated.cost_usd, 0.01)
- self.assertEqual(calls, [("hello", 120.0)])
+ self.assertEqual(calls, [("hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)])
self.assertEqual(closes, [])
def test_v1_generate_closes_fresh_ephemeral_session(self):
@@ -612,7 +612,7 @@ class GatewayModelTest(unittest.TestCase):
self.assertEqual(validated.provider, "claude_cli")
self.assertEqual(validated.cost_usd, 0.02)
self.assertEqual(len(started), 1)
- self.assertEqual(turned, [(started[0], "hello", 120.0)])
+ self.assertEqual(turned, [(started[0], "hello", gateway.GENERATE_TURN_TIMEOUT_SECONDS)])
self.assertEqual(closed, [started[0]])
self.assertNotIn(started[0].id, gateway.SESSIONS)
diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt
index 40464a4..bd0a111 100644
--- a/apps/api/requirements.txt
+++ b/apps/api/requirements.txt
@@ -8,6 +8,8 @@ pydantic-settings==2.12.0
python-multipart==0.0.18
httpx==0.28.1
sse-starlette==3.0.3
+# P4(2026-07-13 회의): 자유 양식 엑셀 업로드 → 텍스트 변환 (personas/sources/upload)
+openpyxl==3.1.5
# ── 선택 의존성 (가드레일 PII 마스킹) ─────────────────────────────
# Presidio 가 설치되면 guardrail.mask_pii 가 NER 기반으로 동작하고,
diff --git a/apps/web/e2e/README.md b/apps/web/e2e/README.md
index 36dcd99..968b44f 100644
--- a/apps/web/e2e/README.md
+++ b/apps/web/e2e/README.md
@@ -48,6 +48,18 @@ $env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-auth.json"
npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth
```
+Public admin visual smoke:
+
+```sh
+# Capture storage state after signing in with an admin-entitled Google account.
+npx playwright codegen https://vignette.chanpaca.net/login --save-storage=./node_modules/.tmp/public-admin-auth.json
+
+# Verify that https://vignette.chanpaca.net/admin visibly renders the admin console.
+$env:E2E_PUBLIC_AUTH="1"
+$env:E2E_PUBLIC_STORAGE_STATE="./node_modules/.tmp/public-admin-auth.json"
+npx playwright test e2e/public-admin-visual.spec.ts --project=chromium-public-auth
+```
+
Notes:
- `E2E_PUBLIC_AUTH=1` targets the public site and does not start the local Vite web server.
diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts
index 08d6d19..0308261 100644
--- a/apps/web/e2e/admin.spec.ts
+++ b/apps/web/e2e/admin.spec.ts
@@ -82,6 +82,7 @@ interface AdminUsageEvaluatorCache {
interface AdminUsageResponse {
source: "database" | "server_session_registry";
durable: boolean;
+ generated_at: number;
window_days: number;
total_turns: number;
metered_turns: number;
@@ -94,6 +95,16 @@ interface AdminUsageResponse {
daily_cost?: AdminUsageDailyCost[];
}
+interface AdminEngineConfigResponse {
+ engine_mode: string;
+ engine_url: string;
+ model: string;
+ source: "database" | "runtime_cache" | "runtime_default";
+ durable: boolean;
+ updated_by: string | null;
+ updated_at: number | null;
+}
+
interface AdminUptimeResponse {
source: "database" | "unavailable";
durable: boolean;
@@ -161,6 +172,8 @@ async function mockAdminSession(
options: {
adminUsers?: unknown[];
adminTickets?: unknown;
+ adminUsage?: AdminUsageResponse;
+ engineConfig?: AdminEngineConfigResponse;
} = {},
) {
const seenAdminEndpoints = new Set();
@@ -221,33 +234,70 @@ async function mockAdminSession(
if (method === "GET" && path.endsWith("/admin/usage")) {
seenAdminEndpoints.add("usage");
+ await fulfillJson(
+ options.adminUsage ?? {
+ source: "database",
+ durable: true,
+ generated_at: 1_783_990_800,
+ window_days: Number(url.searchParams.get("window_days") ?? 7),
+ total_turns: 0,
+ metered_turns: 0,
+ tokens_in: 0,
+ tokens_out: 0,
+ cost_usd: 0,
+ budget: {
+ limit_usd: 0,
+ used_ratio: 0,
+ remaining_usd: null,
+ status: "disabled",
+ },
+ evaluator_cache: {
+ enabled: false,
+ entries: 0,
+ hits: 0,
+ misses: 0,
+ stores: 0,
+ evictions: 0,
+ requests: 0,
+ hit_rate: 0,
+ },
+ by_provider: [],
+ daily_cost: [],
+ },
+ );
+ return;
+ }
+
+ if (method === "GET" && path.endsWith("/admin/engine-config")) {
+ seenAdminEndpoints.add("engine-config");
+ await fulfillJson(
+ options.engineConfig ?? {
+ engine_mode: "openai",
+ engine_url: "http://127.0.0.1:9099",
+ model: "gateway-default",
+ source: "database",
+ durable: true,
+ updated_by: "admin@twentyoz.kr",
+ updated_at: 1_783_990_800,
+ },
+ );
+ return;
+ }
+
+ if (method === "PATCH" && path.endsWith("/admin/engine-config")) {
+ seenAdminEndpoints.add("engine-config-patch");
+ const body = request.postDataJSON() as Partial;
await fulfillJson({
+ ...(options.engineConfig ?? {
+ engine_mode: "openai",
+ engine_url: "http://127.0.0.1:9099",
+ model: "gateway-default",
+ }),
+ ...body,
source: "database",
durable: true,
- window_days: 7,
- total_turns: 0,
- metered_turns: 0,
- tokens_in: 0,
- tokens_out: 0,
- cost_usd: 0,
- budget: {
- limit_usd: 0,
- used_ratio: 0,
- remaining_usd: null,
- status: "disabled",
- },
- evaluator_cache: {
- enabled: false,
- entries: 0,
- hits: 0,
- misses: 0,
- stores: 0,
- evictions: 0,
- requests: 0,
- hit_rate: 0,
- },
- by_provider: [],
- daily_cost: [],
+ updated_by: "stale-admin@twentyoz.kr",
+ updated_at: 1_783_990_900,
});
return;
}
@@ -568,7 +618,9 @@ async function expectVisibleButtonsFit(page: Page, selector: string, context: st
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
- const owner = button.closest(".ad-user,.ad-user-create") ?? button.parentElement;
+ const owner =
+ button.closest(".ad-user,.ad-user-create,.ad-user-table tbody tr") ??
+ button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
@@ -636,6 +688,99 @@ test.describe("admin route guards", () => {
.toEqual(["health", "tickets", "uptime", "usage", "users"]);
});
+ test("shows detailed AI metering and saves the real engine configuration", async ({ page }) => {
+ const seenAdminEndpoints = await mockAdminSession(
+ page,
+ {
+ user_id: "ai-admin",
+ email: "ai-admin@twentyoz.kr",
+ display_name: "AI Admin",
+ role: "admin",
+ admin_access: true,
+ super_admin: true,
+ onboarding_completed_at: 1_782_900_000,
+ },
+ {
+ adminUsage: {
+ source: "database",
+ durable: true,
+ generated_at: 1_783_990_800,
+ window_days: 30,
+ total_turns: 32,
+ metered_turns: 30,
+ tokens_in: 125_000,
+ tokens_out: 18_500,
+ cost_usd: 6.6212,
+ budget: {
+ limit_usd: 20,
+ used_ratio: 0.33106,
+ remaining_usd: 13.3788,
+ status: "ok",
+ },
+ evaluator_cache: {
+ enabled: true,
+ entries: 12,
+ hits: 8,
+ misses: 2,
+ stores: 2,
+ evictions: 1,
+ requests: 10,
+ hit_rate: 0.8,
+ },
+ by_provider: [
+ {
+ provider: "openai",
+ model: "gpt-5-mini",
+ turns: 30,
+ tokens_in: 125_000,
+ tokens_out: 18_500,
+ cost_usd: 6.6212,
+ },
+ ],
+ daily_cost: [
+ { day: "2026-07-13", turns: 8, tokens_in: 32_000, tokens_out: 4_800, cost_usd: 1.42 },
+ { day: "2026-07-14", turns: 10, tokens_in: 41_000, tokens_out: 6_100, cost_usd: 2.08 },
+ { day: "2026-07-15", turns: 12, tokens_in: 52_000, tokens_out: 7_600, cost_usd: 3.1212 },
+ ],
+ },
+ },
+ );
+
+ await page.goto("/admin/ai");
+
+ await expect(page).toHaveURL(/\/admin\/ai$/);
+ await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible();
+ await expect(page.locator(".vg-nav").getByRole("link", { name: "AI 운영" })).toHaveAttribute(
+ "aria-current",
+ "page",
+ );
+ await expect(page.getByText("운영 DB 원장").first()).toBeVisible();
+ await expect(page.locator(".aic-ledger")).toContainText("$6.6212");
+ await expect(page.locator(".aic-budget")).toContainText("93.8%");
+ await expect(page.locator(".aic-table")).toContainText("gpt-5-mini");
+ await expect(page.locator(".aic-cache-score")).toContainText("80%");
+ await expect(page.getByLabel("AI 기본 모델")).toHaveValue("gateway-default");
+
+ const usageRequest = page.waitForRequest((request) =>
+ request.url().includes("/api/admin/usage?window_days=7"),
+ );
+ await page.getByRole("button", { name: "7일" }).click();
+ await usageRequest;
+
+ await page.getByLabel("AI 기본 모델").fill("gpt-5.1-mini");
+ const patchRequest = page.waitForRequest((request) =>
+ request.method() === "PATCH" && request.url().endsWith("/api/admin/engine-config"),
+ );
+ await page.getByRole("button", { name: "운영 설정 저장" }).click();
+ const request = await patchRequest;
+ expect(request.postDataJSON()).toMatchObject({ model: "gpt-5.1-mini" });
+ await expect(page.getByText("저장됨")).toBeVisible();
+ await expect
+ .poll(() => Array.from(seenAdminEndpoints).sort())
+ .toEqual(["engine-config", "engine-config-patch", "health", "usage"]);
+ await expectNoHorizontalOverflow(page);
+ });
+
test("resets scroll when moving from long admin pages to access policy", async ({ page }) => {
await mockAdminSession(page, {
user_id: "scroll-admin",
@@ -663,7 +808,40 @@ test.describe("admin route guards", () => {
await expect
.poll(() => page.evaluate(() => window.scrollY))
.toBe(0);
- await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeInViewport();
+ await expect(page.locator(".ad-root")).toBeInViewport();
+ await expect(page.getByRole("heading", { name: "역할, 그룹, 접근 범위" })).toBeVisible();
+ });
+
+ test("resets scroll when the browser restores an existing admin tab", async ({ page }) => {
+ await mockAdminSession(page, {
+ user_id: "restored-tab-admin",
+ email: "restored-tab-admin@twentyoz.kr",
+ display_name: "Restored Tab Admin",
+ role: "admin",
+ admin_access: true,
+ super_admin: true,
+ onboarding_completed_at: 1_782_900_000,
+ });
+
+ await page.goto("/admin");
+ await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
+ const scrolled = await page.evaluate(() => {
+ document.documentElement.style.minHeight = "2200px";
+ document.body.style.minHeight = "2200px";
+ window.scrollTo(0, 900);
+ return window.scrollY;
+ });
+ expect(scrolled).toBeGreaterThan(0);
+
+ await page.evaluate(() => {
+ window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
+ });
+
+ await expect
+ .poll(() => page.evaluate(() => window.scrollY))
+ .toBe(0);
+ await expect(page.locator(".ad-root")).toBeInViewport();
+ await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
});
test("shows admin data diagnostics instead of a blank main pane", async ({ page }) => {
@@ -849,6 +1027,7 @@ test.describe("admin route", () => {
await page.goto("/admin");
const nav = page.locator(".vg-nav");
await expect(nav.getByRole("link", { name: "운영 홈" })).toBeVisible();
+ await expect(nav.getByRole("link", { name: "AI 운영" })).toBeVisible();
await expect(nav.getByRole("link", { name: "사용자" })).toBeVisible();
await expect(nav.getByRole("link", { name: "권한" })).toBeVisible();
await expect(nav.getByRole("link", { name: "티켓" })).toBeVisible();
@@ -886,6 +1065,18 @@ test.describe("admin route", () => {
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
+ const userTable = page.getByRole("table");
+ await expect(userTable).toBeVisible();
+ const sessionHeader = page.getByRole("columnheader", { name: /활성 회기/ });
+ await sessionHeader.getByRole("button").click();
+ await expect(sessionHeader).toHaveAttribute("aria-sort", "ascending");
+ const ascendingSessions = await userTable.locator("tbody tr td:nth-child(7)").allTextContents();
+ expect(ascendingSessions.map(Number)).toEqual(
+ ascendingSessions.map(Number).slice().sort((a, b) => a - b),
+ );
+ await sessionHeader.getByRole("button").click();
+ await expect(sessionHeader).toHaveAttribute("aria-sort", "descending");
+
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
@@ -934,9 +1125,9 @@ test.describe("admin route", () => {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
- const card = page.locator(".ad-user").filter({ hasText: email });
+ const card = page.locator(".ad-user-table tbody tr").filter({ hasText: email });
await expect(card).toBeVisible();
- await expect(card).toContainText(displayName);
+ await expect(card.getByLabel(`${email} 표시 이름`)).toHaveValue(displayName);
await expect(card).toContainText("승인됨");
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
@@ -970,7 +1161,7 @@ test.describe("admin route", () => {
cohort_ids: [nextCohort],
});
- await expect(card).toContainText(nextName);
+ await expect(nameInput).toHaveValue(nextName);
await expect(card).toContainText("교수자");
await expect(cohortInput).toHaveValue(nextCohort);
@@ -1136,6 +1327,20 @@ test.describe("admin route", () => {
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await page.getByRole("tab", { name: "사용자 목록" }).click();
+ const scrollRegion = page.locator(".ad-user-table-scroll");
+ const scrollContract = await scrollRegion.evaluate((element) => {
+ const before = element.scrollLeft;
+ element.scrollLeft = element.scrollWidth;
+ return {
+ before,
+ after: element.scrollLeft,
+ clientWidth: element.clientWidth,
+ scrollWidth: element.scrollWidth,
+ };
+ });
+ expect(scrollContract.scrollWidth).toBeGreaterThan(scrollContract.clientWidth);
+ expect(scrollContract.after).toBeGreaterThan(scrollContract.before);
+ await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible();
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});
diff --git a/apps/web/e2e/auth-visual.spec.ts b/apps/web/e2e/auth-visual.spec.ts
new file mode 100644
index 0000000..549df91
--- /dev/null
+++ b/apps/web/e2e/auth-visual.spec.ts
@@ -0,0 +1,117 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { expect, test, type Page } from "@playwright/test";
+import { expectNoHorizontalOverflow } from "./support";
+
+const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "auth-visual");
+const THEMES = ["light", "dark"] as const;
+const VIEWPORTS = [
+ { width: 390, height: 844, label: "mobile" },
+ { width: 1280, height: 800, label: "desktop" },
+] as const;
+
+async function setTheme(page: Page, theme: (typeof THEMES)[number], pathName: string) {
+ await page.goto(pathName);
+ await page.evaluate((nextTheme) => {
+ localStorage.setItem("vignette.theme", nextTheme);
+ }, theme);
+ await page.reload();
+}
+
+async function expectGlassSurface(page: Page, selector: string) {
+ const surface = await page.locator(selector).evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ backgroundImage: style.backgroundImage,
+ backdropFilter: style.backdropFilter || style.webkitBackdropFilter,
+ borderColor: style.borderColor,
+ boxShadow: style.boxShadow,
+ };
+ });
+ expect(surface.backgroundImage.match(/linear-gradient/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
+ expect(surface.backdropFilter).toContain("blur(");
+ expect(surface.borderColor).not.toBe("rgba(0, 0, 0, 0)");
+ expect(surface.boxShadow).not.toBe("none");
+}
+
+test("@single-run 인증 화면이 공통 테마와 전체 viewport를 유지한다", async ({ page }) => {
+ await fs.mkdir(SHOT_DIR, { recursive: true });
+ const loginRoomBackgrounds = new Map<(typeof THEMES)[number], string>();
+
+ for (const theme of THEMES) {
+ for (const viewport of VIEWPORTS) {
+ await page.setViewportSize(viewport);
+ await setTheme(page, theme, "/login");
+ await expect(page.locator(".lg-panel")).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
+ await expectNoHorizontalOverflow(page);
+ await expectGlassSurface(page, ".lg-panel");
+
+ const themeColors = await page.locator(".lg-root").evaluate((root) => {
+ const brand = root.querySelector(".lg-brand");
+ const panel = root.querySelector(".lg-panel");
+ const shellStyle = getComputedStyle(root);
+ if (!brand || !panel) throw new Error("login theme surfaces are missing");
+ const luma = (color: string) => {
+ const [red = 0, green = 0, blue = 0] = color.match(/\d+(?:\.\d+)?/g)?.map(Number) ?? [];
+ return red * 0.2126 + green * 0.7152 + blue * 0.0722;
+ };
+ return {
+ brandLuma: luma(getComputedStyle(brand).color),
+ panelLuma: luma(getComputedStyle(panel).color),
+ roomBackground: shellStyle.backgroundImage,
+ };
+ });
+ if (theme === "light") {
+ expect(themeColors.brandLuma).toBeLessThan(96);
+ expect(themeColors.panelLuma).toBeLessThan(96);
+ } else {
+ expect(themeColors.brandLuma).toBeGreaterThan(180);
+ expect(themeColors.panelLuma).toBeGreaterThan(180);
+ }
+ loginRoomBackgrounds.set(theme, themeColors.roomBackground);
+
+ const canvas = await page.locator(".vg-auth-shell").evaluate((element) => {
+ const rect = element.getBoundingClientRect();
+ return {
+ width: Math.round(rect.width),
+ minHeight: Math.round(rect.height),
+ viewportWidth: document.documentElement.clientWidth,
+ viewportHeight: window.innerHeight,
+ };
+ });
+ expect(canvas.width).toBe(canvas.viewportWidth);
+ expect(canvas.minHeight).toBeGreaterThanOrEqual(canvas.viewportHeight);
+
+ await page.screenshot({
+ path: path.join(SHOT_DIR, `login-${theme}-${viewport.label}.png`),
+ fullPage: true,
+ });
+ }
+ }
+ expect(loginRoomBackgrounds.get("light")).not.toBe(loginRoomBackgrounds.get("dark"));
+
+ const login = await page.request.post("/api/auth/dev-login", {
+ data: {
+ email: `auth-visual.${Date.now()}@hs.ac.kr`,
+ role: "learner",
+ display_name: "인증 화면 검증 학습자",
+ },
+ });
+ expect(login.ok(), await login.text()).toBeTruthy();
+
+ for (const theme of THEMES) {
+ for (const viewport of VIEWPORTS) {
+ await page.setViewportSize(viewport);
+ await setTheme(page, theme, "/onboarding");
+ await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", theme);
+ await expectNoHorizontalOverflow(page);
+ await expectGlassSurface(page, ".ob-shell");
+ await page.screenshot({
+ path: path.join(SHOT_DIR, `onboarding-${theme}-${viewport.label}.png`),
+ fullPage: true,
+ });
+ }
+ }
+});
diff --git a/apps/web/e2e/avatar-expression.spec.ts b/apps/web/e2e/avatar-expression.spec.ts
index e6af696..f2012a0 100644
--- a/apps/web/e2e/avatar-expression.spec.ts
+++ b/apps/web/e2e/avatar-expression.spec.ts
@@ -106,7 +106,7 @@ async function mockSessionDetail(
test.describe("persona avatar expression rig", () => {
test.beforeEach(async ({ page }) => {
- await page.route("**/api/auth/me", (route) =>
+ await page.route(/\/(?:api\/)?auth\/me(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -122,7 +122,7 @@ test.describe("persona avatar expression rig", () => {
}),
);
- await page.route("**/api/personas", (route) =>
+ await page.route(/\/(?:api\/)?personas(?:\?.*)?$/, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
@@ -137,6 +137,34 @@ test.describe("persona avatar expression rig", () => {
),
}),
);
+
+ await page.route(/\/(?:api\/)?voice\/health(?:\?.*)?$/, (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({ available: false, reason: "test fixture" }),
+ }),
+ );
+
+ await page.route(/\/(?:api\/)?sessions\/[^/]+\/live-coach(?:\?.*)?$/, (route) => {
+ if (route.request().method() === "GET") {
+ return route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ session_id: "avatar-expression-session",
+ events: [],
+ quota: { used: 0, limit: 3, remaining: 3 },
+ source: "database",
+ }),
+ });
+ }
+ return route.fulfill({
+ status: 503,
+ contentType: "application/json",
+ body: JSON.stringify({ detail: "live coach is outside this avatar fixture" }),
+ });
+ });
});
test("renders every persona with a distinct baseline expression and at least 20 expressions", async ({
@@ -158,17 +186,12 @@ test.describe("persona avatar expression rig", () => {
live2dMotionFile: el.getAttribute("data-live2d-motion-file"),
live2dExpressionCount: Number(el.getAttribute("data-live2d-expression-count")),
renderMode: el.getAttribute("data-render-mode"),
- rasterArtSet: el.querySelector(".vg-raster")?.getAttribute("data-raster-art-set"),
- rasterMode: el.querySelector(".vg-raster")?.getAttribute("data-raster-mode"),
- rasterLayerCount: el.querySelectorAll(".vg-raster__layer").length,
- rasterBox: el.querySelector(".vg-raster")?.getBoundingClientRect().toJSON(),
+ rasterCount: el.querySelectorAll(".vg-raster").length,
primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: el.querySelector('[data-avatar-neck="true"]')?.getBoundingClientRect().toJSON(),
svgBox: el.querySelector("svg")?.getBoundingClientRect().toJSON(),
}));
- const expectedArtSet = `${persona.code.toLowerCase()}-live2d-generated`;
-
expect(metrics.expressionCount, `${persona.code} expression count`).toBeGreaterThanOrEqual(20);
expect(metrics.live2dSchema, `${persona.code} Live2D schema`).toBe("vignette.live2d.v1");
expect(metrics.live2dModel, `${persona.code} Live2D model`).toBe(persona.expectedModel);
@@ -180,15 +203,12 @@ test.describe("persona avatar expression rig", () => {
`expressions/${persona.expectedExpression}.exp3.json`,
);
expect(metrics.live2dExpressionCount, `${persona.code} Live2D expressions`).toBeGreaterThanOrEqual(20);
- expect(metrics.renderMode, `${persona.code} render mode`).toBe("raster");
- expect(metrics.rasterArtSet, `${persona.code} generated art set`).toBe(expectedArtSet);
- expect(metrics.rasterMode, `${persona.code} raster rig mode`).toBe("psb-detailed");
- expect(metrics.rasterLayerCount, `${persona.code} raster layers`).toBeGreaterThanOrEqual(20);
- expect(metrics.rasterBox?.width, `${persona.code} raster width`).toBeGreaterThan(0);
- expect(metrics.rasterBox?.height, `${persona.code} raster height`).toBeGreaterThan(0);
- expect(metrics.primitiveCount, `${persona.code} fallback SVG primitives`).toBe(0);
- expect(metrics.neckBox, `${persona.code} SVG neck fallback`).toBeUndefined();
- expect(metrics.svgBox, `${persona.code} SVG fallback`).toBeUndefined();
+ expect(metrics.renderMode, `${persona.code} render mode`).toBe("svg");
+ expect(metrics.rasterCount, `${persona.code} raster rig should be disabled`).toBe(0);
+ expect(metrics.primitiveCount, `${persona.code} SVG primitives`).toBeGreaterThan(10);
+ expect(metrics.neckBox?.width, `${persona.code} SVG neck`).toBeGreaterThan(0);
+ expect(metrics.svgBox?.width, `${persona.code} SVG rig`).toBeGreaterThan(0);
+ expect(metrics.svgBox?.height, `${persona.code} SVG rig`).toBeGreaterThan(0);
}
});
@@ -221,8 +241,9 @@ test.describe("persona avatar expression rig", () => {
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toBeVisible();
- await expect(activeAvatar).toHaveAttribute("data-render-mode", "raster");
- await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).toBeVisible();
+ await expect(activeAvatar).toHaveAttribute("data-render-mode", "svg");
+ await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
+ await expect(activeAvatar.locator(".vg-avatar__svg")).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-model", "vignette-p4-live2d");
@@ -275,8 +296,9 @@ test.describe("persona avatar expression rig", () => {
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page--active")).toBeVisible();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
- await expect(activeAvatar).toHaveAttribute("data-render-mode", "raster");
- await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).toBeVisible();
+ await expect(activeAvatar).toHaveAttribute("data-render-mode", "svg");
+ await expect(activeAvatar.locator(".vg-raster")).toHaveCount(0);
+ await expect(activeAvatar.locator(".vg-avatar__svg")).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
await setLearnerUtterance(page, "조금 안정된 것 같아요.");
@@ -295,18 +317,15 @@ test.describe("persona avatar expression rig", () => {
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
});
- test("uses the PSD v2 crying raster rig for P1 Seoyeon", async ({ page }) => {
+ test("uses the original SVG parameter rig for P1 Seoyeon", async ({ page }) => {
await page.goto("/learn/session/P1");
const avatar = page.locator('.vg-avatar[data-persona-code="P1"]').first();
await expect(avatar).toBeVisible();
- await expect(avatar).toHaveAttribute("data-render-mode", "raster");
+ await expect(avatar).toHaveAttribute("data-render-mode", "svg");
await expect(avatar).toHaveAttribute("data-affect", "sad");
-
- const raster = avatar.locator('.vg-raster[data-raster-art-set="seoyeon-live2d-psd-v2"]');
- await expect(raster).toBeVisible();
- await expect(raster).toHaveAttribute("data-raster-variant", "sad");
- await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-left.png"]')).toHaveCount(1);
- await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-right.png"]')).toHaveCount(1);
+ await expect(avatar.locator(".vg-raster")).toHaveCount(0);
+ await expect(avatar.locator(".vg-avatar__svg")).toBeVisible();
+ await expect(avatar.locator('[data-avatar-neck="true"]')).toBeVisible();
});
});
diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts
index 96ecbf5..9e4d009 100644
--- a/apps/web/e2e/layout-visual-gate.spec.ts
+++ b/apps/web/e2e/layout-visual-gate.spec.ts
@@ -260,12 +260,37 @@ async function gateScreen(
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
await prepareReady();
+ await page.evaluate(() => {
+ window.scrollTo(0, 0);
+ return new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+ });
await expect(
page.locator("html"),
`[${screen} @ ${vp.label}] layout gate must capture the dark UI surface`,
).toHaveAttribute("data-theme", "dark");
await expectNoHorizontalOverflow(page);
+ const navGeometry = await page.evaluate(() => {
+ const nav = document.querySelector(".vg-nav");
+ const label = document.querySelector(".vg-nav__label");
+ const topbar = document.querySelector(".vg-topbar");
+ if (!nav || !label || !topbar || getComputedStyle(label).display === "none") return null;
+ const navRect = nav.getBoundingClientRect();
+ return {
+ innerGap: Math.round(label.getBoundingClientRect().top - navRect.top),
+ shellGap: Math.round(navRect.top - topbar.getBoundingClientRect().bottom),
+ };
+ });
+ if (navGeometry !== null) {
+ expect(
+ navGeometry.innerGap,
+ `[${screen} @ ${vp.label}] GNB should start near the topbar without a dead top zone`,
+ ).toBeLessThanOrEqual(40);
+ expect(
+ Math.abs(navGeometry.shellGap),
+ `[${screen} @ ${vp.label}] GNB should begin directly below the topbar`,
+ ).toBeLessThanOrEqual(1);
+ }
const report = await auditClipping(page);
expect(
report.horizontalOverflow,
@@ -287,6 +312,14 @@ async function gateScreen(
}
}
+/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
+async function openReviewTabIfPresent(page: Page, label: string) {
+ const tab = page.locator(".sr-tabs button", { hasText: label });
+ if (await tab.isVisible().catch(() => false)) {
+ await tab.click();
+ }
+}
+
async function expectEmptyReviewNoDeadThirdColumn(page: Page) {
const report = await page.evaluate(() => {
const root = document.querySelector(".sr-root--empty");
@@ -312,10 +345,7 @@ async function expectEmptyReviewNoDeadThirdColumn(page: Page) {
});
expect(report.present, "empty review layout should be mounted").toBe(true);
- expect(
- report.columnCount,
- "empty review should collapse the desktop masonry layout instead of leaving a sparse third column",
- ).toBeLessThanOrEqual(2);
+ expect(report.columnCount, "empty review should keep the single-pane tab layout").toBe(1);
}
async function expectFilledReviewLearnerWorkbench(page: Page) {
@@ -323,52 +353,35 @@ async function expectFilledReviewLearnerWorkbench(page: Page) {
const cols = document.querySelector(".sr-cols--learner");
const transcript = document.querySelector(".sr-card--transcript");
const overview = document.querySelector(".sr-overview");
- const rubric = document.querySelector(".sr-card--rubric");
- const worksheet = document.querySelector(".sr-card--worksheet");
- const prepost = document.querySelector(".sr-card--prepost");
- if (!cols || !transcript || !overview || !rubric || !worksheet || !prepost) {
+ if (!cols || !transcript || !overview) {
return {
present: false,
- viewportWidth: window.innerWidth,
columnCount: 0,
- overviewRight: 0,
- transcriptLeft: 0,
- transcriptRight: 0,
- rubricLeft: 0,
- worksheetLeft: 0,
- worksheetRight: 0,
- prepostLeft: 0,
+ overviewBottom: 0,
+ colsTop: 0,
+ transcriptWidth: 0,
+ colsWidth: 0,
};
}
const columnCount = getComputedStyle(cols).gridTemplateColumns.split(" ").filter(Boolean).length;
+ const colsRect = cols.getBoundingClientRect();
const transcriptRect = transcript.getBoundingClientRect();
const overviewRect = overview.getBoundingClientRect();
- const rubricRect = rubric.getBoundingClientRect();
- const worksheetRect = worksheet.getBoundingClientRect();
- const prepostRect = prepost.getBoundingClientRect();
return {
present: true,
- viewportWidth: window.innerWidth,
columnCount,
- overviewRight: Math.ceil(overviewRect.right),
- transcriptLeft: Math.floor(transcriptRect.left),
- transcriptRight: Math.ceil(transcriptRect.right),
- rubricLeft: Math.floor(rubricRect.left),
- worksheetLeft: Math.floor(worksheetRect.left),
- worksheetRight: Math.ceil(worksheetRect.right),
- prepostLeft: Math.floor(prepostRect.left),
+ overviewBottom: Math.ceil(overviewRect.bottom),
+ colsTop: Math.floor(colsRect.top),
+ transcriptWidth: Math.round(transcriptRect.width),
+ colsWidth: Math.round(colsRect.width),
};
});
expect(report.present, "filled learner review layout should be mounted").toBe(true);
- if (report.viewportWidth > 1180) {
- expect(report.columnCount, "desktop learner review should use a 3-column workbench").toBe(3);
- expect(report.overviewRight).toBeLessThanOrEqual(report.transcriptLeft);
- expect(report.transcriptRight).toBeLessThanOrEqual(report.rubricLeft);
- expect(report.worksheetLeft).toBeLessThan(report.transcriptLeft);
- expect(report.worksheetRight).toBeLessThanOrEqual(report.prepostLeft);
- }
+ expect(report.columnCount, "learner review should use one active tab pane").toBe(1);
+ expect(report.overviewBottom).toBeLessThanOrEqual(report.colsTop + 1);
+ expect(Math.abs(report.transcriptWidth - report.colsWidth)).toBeLessThanOrEqual(2);
}
async function expectSupervisorReviewNoDeadGaps(page: Page) {
@@ -379,7 +392,6 @@ async function expectSupervisorReviewNoDeadGaps(page: Page) {
if (!cols || !left || !right) {
return {
present: false,
- viewportWidth: window.innerWidth,
columnCount: 0,
leftDisplay: "",
rightDisplay: "",
@@ -388,57 +400,23 @@ async function expectSupervisorReviewNoDeadGaps(page: Page) {
};
}
- function maxVerticalGap(container: HTMLElement) {
- const intervals = Array.from(container.children)
- .map((child) => child.getBoundingClientRect())
- .filter((rect) => rect.width > 0 && rect.height > 0)
- .map((rect) => ({ top: rect.top, bottom: rect.bottom }))
- .sort((a, b) => a.top - b.top || a.bottom - b.bottom);
- if (intervals.length < 2) return 0;
-
- let maxGap = 0;
- let currentBottom = intervals[0].bottom;
- for (let index = 1; index < intervals.length; index += 1) {
- const next = intervals[index];
- if (next.top <= currentBottom) {
- currentBottom = Math.max(currentBottom, next.bottom);
- continue;
- }
- const gap = next.top - currentBottom;
- if (gap > maxGap) maxGap = gap;
- currentBottom = next.bottom;
- }
- return Math.round(maxGap);
- }
-
const colsStyle = getComputedStyle(cols);
const leftStyle = getComputedStyle(left);
const rightStyle = getComputedStyle(right);
return {
present: true,
- viewportWidth: window.innerWidth,
columnCount: colsStyle.gridTemplateColumns.split(" ").filter(Boolean).length,
leftDisplay: leftStyle.display,
rightDisplay: rightStyle.display,
- maxMainGap: maxVerticalGap(left),
- maxSideGap: maxVerticalGap(right),
+ maxMainGap: 0,
+ maxSideGap: 0,
};
});
expect(report.present, "supervisor review layout should be mounted").toBe(true);
- if (report.viewportWidth > 1180) {
- expect(report.columnCount, "desktop supervisor review should use main + review rail").toBe(2);
- expect(report.leftDisplay).toBe("grid");
- expect(report.rightDisplay).toBe("grid");
- expect(
- report.maxMainGap,
- "desktop supervisor review main column should not leave a dead vertical void",
- ).toBeLessThanOrEqual(40);
- expect(
- report.maxSideGap,
- "desktop supervisor review side rail should not leave a dead vertical void",
- ).toBeLessThanOrEqual(40);
- }
+ expect(report.columnCount, "supervisor review should use one active tab pane").toBe(1);
+ expect(report.leftDisplay).toBe("none");
+ expect(report.rightDisplay).toBe("none");
}
test.describe("layout visual gate @single-run", () => {
@@ -484,10 +462,99 @@ test.describe("layout visual gate @single-run", () => {
timeout: 15_000,
});
await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
- await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
- timeout: 15_000,
+ // 대시보드 탭은 모든 폭에서 상시 노출된다 — 탭별 콘텐츠를 확인 후 기본 탭으로 복귀.
+ const dashTabs = page.locator(".lh-tabs");
+ if (await dashTabs.isVisible().catch(() => false)) {
+ await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
+ await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
+ timeout: 15_000,
+ });
+ await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
+ await expect(page.locator(".lh-work-cluster")).toBeVisible({ timeout: 15_000 });
+ } else {
+ await expect(page.locator(".lh-compact-list li").first()).toBeVisible({
+ timeout: 15_000,
+ });
+ }
+
+ const shellScroll = await page.evaluate(async () => {
+ const nav = document.querySelector(".vg-nav");
+ const main = document.querySelector(".vg-main");
+ if (!nav || !main) return null;
+
+ const navTopBefore = nav.getBoundingClientRect().top;
+ const windowScrollBefore = window.scrollY;
+ const maxScroll = Math.max(0, main.scrollHeight - main.clientHeight);
+ main.scrollTop = Math.min(240, maxScroll);
+ await new Promise((resolve) => requestAnimationFrame(() => resolve(null)));
+ const report = {
+ maxScroll,
+ mainScrollTop: main.scrollTop,
+ navTopDelta: Math.abs(nav.getBoundingClientRect().top - navTopBefore),
+ windowScrollDelta: Math.abs(window.scrollY - windowScrollBefore),
+ };
+ main.scrollTop = 0;
+ return report;
});
+ expect(shellScroll, "learner shell should include nav and main scroll frame").not.toBeNull();
+ expect(shellScroll!.maxScroll, "main content should own the vertical overflow").toBeGreaterThan(0);
+ expect(shellScroll!.mainScrollTop, "main content should scroll independently").toBeGreaterThan(0);
+ expect(shellScroll!.navTopDelta, "GNB should remain fixed while main content scrolls").toBeLessThanOrEqual(1);
+ expect(shellScroll!.windowScrollDelta, "document should not be the app scroll owner").toBeLessThanOrEqual(1);
});
+
+ await page.setViewportSize({ width: 1200, height: 1320 });
+ await expect(page.locator(".lh-tabs")).toBeVisible();
+ await page.screenshot({
+ path: path.join(SHOT_DIR, "learner-home__1200-reference-dark.png"),
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "라이트 모드로" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ const lightAssets = await page.evaluate(() => {
+ const card = document.querySelector(".lh-metric-card");
+ const shellBody = document.querySelector(".vg-shell--learner-dashboard .vg-shell__body");
+ const nav = document.querySelector(".vg-shell--learner-dashboard .vg-nav");
+ return {
+ card: card ? getComputedStyle(card, "::after").backgroundImage : "",
+ cardSurface: card
+ ? {
+ backgroundImage: getComputedStyle(card).backgroundImage,
+ backdropFilter: getComputedStyle(card).backdropFilter,
+ boxShadow: getComputedStyle(card).boxShadow,
+ }
+ : null,
+ shellBody: shellBody ? getComputedStyle(shellBody).backgroundImage : "",
+ nav: nav ? getComputedStyle(nav).backgroundImage : "",
+ };
+ });
+ expect(lightAssets.card).toContain("card-leaf-sprig-light.png");
+ expect(lightAssets.cardSurface).not.toBeNull();
+ expect(lightAssets.cardSurface!.backgroundImage.match(/linear-gradient/g)?.length ?? 0).toBeGreaterThanOrEqual(2);
+ expect(lightAssets.cardSurface!.backdropFilter).toContain("blur(");
+ expect(lightAssets.cardSurface!.boxShadow).not.toBe("none");
+ expect(lightAssets.shellBody).toContain("background-light-corner.png");
+ expect(lightAssets.nav).toContain("background-light-sidebar.png");
+ await page.screenshot({
+ path: path.join(SHOT_DIR, "learner-home__1200-reference-light.png"),
+ fullPage: true,
+ });
+ await page.setViewportSize({ width: 390, height: 844 });
+ await page.evaluate(() => window.scrollTo(0, 0));
+ const lightMobileReport = await auditClipping(page);
+ expect(lightMobileReport.horizontalOverflow, "[learner-home light @ 390] horizontal overflow").toBeLessThanOrEqual(1);
+ expect(lightMobileReport.offenders, "[learner-home light @ 390] clipped controls").toEqual([]);
+ await expect
+ .poll(() =>
+ page.locator(".vg-shell--learner-dashboard .vg-shell__body").evaluate((element) => getComputedStyle(element).backgroundImage),
+ )
+ .toContain("background-light-corner.png");
+ await page.screenshot({
+ path: path.join(SHOT_DIR, "learner-home__390-reference-light.png"),
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "다크 모드로" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
test("session prestart stays contained across all widths", async ({ page }) => {
@@ -498,7 +565,46 @@ test.describe("layout visual gate @single-run", () => {
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible({
timeout: 15_000,
});
+ const plan = page.locator(".sx-prestart__plan");
+ await expect(plan).toContainText("시작 과업");
+ await expect(plan).toContainText("선택 접근");
+ await expect(plan).toContainText("이번 목표");
+ await expect(plan).toContainText("운영 기준");
+
+ const widths = await page.evaluate(() => {
+ const head = document.querySelector(".sx-page--prestart .sx-head");
+ const prestart = document.querySelector(".sx-page--prestart .sx-prestart");
+ return {
+ head: head?.getBoundingClientRect().width ?? 0,
+ prestart: prestart?.getBoundingClientRect().width ?? 0,
+ };
+ });
+ expect(widths.head).toBeGreaterThan(0);
+ expect(
+ Math.abs(widths.head - widths.prestart),
+ `prestart width ${widths.prestart}px should align with head width ${widths.head}px`,
+ ).toBeLessThanOrEqual(1);
});
+
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.getByRole("button", { name: "라이트 모드로" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await expect(page.locator(".sx-prestart")).toHaveClass(/vg-surface--panel/);
+ await expect(page.locator(".sx-prestart__plan")).toHaveClass(/vg-surface--inset/);
+ const insetSurface = await page.locator(".sx-prestart__plan").evaluate((element) => {
+ const style = getComputedStyle(element);
+ return {
+ borderTopWidth: style.borderTopWidth,
+ borderRadius: style.borderRadius,
+ };
+ });
+ expect(insetSurface).toEqual({ borderTopWidth: "0px", borderRadius: "0px" });
+ await page.screenshot({
+ path: path.join(SHOT_DIR, "session-prestart__1280-reference-light.png"),
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "다크 모드로" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
test("active session keeps controls contained across all widths", async ({ page }) => {
@@ -519,7 +625,9 @@ test.describe("layout visual gate @single-run", () => {
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
+ await openReviewTabIfPresent(page, "워크시트");
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
+ await openReviewTabIfPresent(page, "축어록");
await expectFilledReviewLearnerWorkbench(page);
});
});
@@ -530,7 +638,9 @@ test.describe("layout visual gate @single-run", () => {
await page.goto(`/teach/session/${TEACHER_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review-professor", async () => {
await expect(page.locator(".sr-cols--supervisor")).toBeVisible({ timeout: 15_000 });
+ await openReviewTabIfPresent(page, "피드백");
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
+ await openReviewTabIfPresent(page, "축어록");
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expectSupervisorReviewNoDeadGaps(page);
});
@@ -544,7 +654,9 @@ test.describe("layout visual gate @single-run", () => {
await gateScreen(page, "session-review-empty", async () => {
await expect(page.locator(".sr-root--empty")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("축어록 저장 후 생성")).toBeVisible();
+ await openReviewTabIfPresent(page, "피드백");
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
+ await openReviewTabIfPresent(page, "축어록");
await expectEmptyReviewNoDeadThirdColumn(page);
});
});
@@ -618,16 +730,23 @@ test.describe("layout visual gate @single-run", () => {
});
});
- test("persona studio stays contained across all widths", async ({ page }) => {
+ test("persona workspace stays contained across all widths", async ({ page }) => {
await signInAsTeacher(page);
await page.goto("/teach/personas");
- await gateScreen(page, "persona-studio", async () => {
- await expect(page.locator(".ps-layout")).toBeVisible({ timeout: 15_000 });
- await expect(page.getByText("내담자 설계·검수 작업면")).toBeVisible();
- await page.getByRole("tab", { name: "프롬프트" }).click();
- const promptPreview = page.getByLabel("프롬프트 미리보기");
- await expect(promptPreview).toBeVisible();
- await expect(promptPreview.getByRole("textbox")).toHaveCount(0);
+ await gateScreen(page, "persona-workspace", async () => {
+ await expect(page.locator(".ps-overview")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
+ await expect(page.getByRole("navigation", { name: "페르소나 관리 영역" })).toBeVisible();
+ });
+ });
+
+ test("persona authoring steps stay contained across all widths", async ({ page }) => {
+ await signInAsTeacher(page);
+ await page.goto("/teach/personas?view=personas&mode=create&step=edit");
+ await gateScreen(page, "persona-authoring", async () => {
+ await expect(page.locator(".ps-authoring-layout")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
+ await expect(page.getByRole("tab", { name: "개요" })).toBeVisible();
});
});
@@ -641,6 +760,69 @@ test.describe("layout visual gate @single-run", () => {
});
});
+ test("admin AI operations stays contained across all widths", async ({ page }) => {
+ await page.request.post("/api/auth/dev-login", {
+ data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },
+ });
+ await page.goto("/admin/ai");
+ await gateScreen(page, "admin-ai", async () => {
+ await expect(page.getByRole("heading", { name: "AI 운영과 DB 계량" })).toBeVisible({
+ timeout: 15_000,
+ });
+ await expect(page.locator(".aic-grid")).toBeVisible();
+ });
+ });
+
+ test("admin user table keeps its own horizontal scroll", async ({ page }) => {
+ test.setTimeout(60_000);
+ await page.request.post("/api/auth/dev-login", {
+ data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },
+ });
+ await page.goto("/admin/users");
+ await page.getByRole("tab", { name: "사용자 목록" }).evaluate((element) => element.click());
+ await expect(page.getByRole("table")).toBeVisible({ timeout: 15_000 });
+
+ for (const viewport of [
+ { width: 1280, height: 800, label: "desktop" },
+ { width: 390, height: 844, label: "mobile" },
+ ]) {
+ await page.setViewportSize(viewport);
+ await expectNoHorizontalOverflow(page);
+ const scrollReport = await page.locator(".ad-user-table-scroll").evaluate((element) => {
+ element.scrollLeft = 0;
+ const report = {
+ clientWidth: element.clientWidth,
+ scrollWidth: element.scrollWidth,
+ initialScroll: element.scrollLeft,
+ };
+ element.scrollLeft = element.scrollWidth;
+ return { ...report, finalScroll: element.scrollLeft };
+ });
+ expect(scrollReport.scrollWidth, `[admin users @ ${viewport.label}] table min width`).toBeGreaterThan(
+ scrollReport.clientWidth,
+ );
+ expect(scrollReport.finalScroll, `[admin users @ ${viewport.label}] horizontal scroll`).toBeGreaterThan(
+ scrollReport.initialScroll,
+ );
+ await expect(page.getByRole("columnheader", { name: /작업/ })).toBeVisible();
+ await page.locator(".ad-user-table-scroll").evaluate((element) => {
+ element.scrollLeft = 0;
+ });
+ await page.screenshot({
+ path: path.join(SHOT_DIR, `admin-users__${viewport.width}-reference-dark.png`),
+ fullPage: true,
+ });
+ }
+
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.getByRole("button", { name: "라이트 모드로" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await page.screenshot({
+ path: path.join(SHOT_DIR, "admin-users__1280-reference-light.png"),
+ fullPage: true,
+ });
+ });
+
test("settings stays contained across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },
@@ -649,5 +831,15 @@ test.describe("layout visual gate @single-run", () => {
await gateScreen(page, "settings", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
});
+ await page.setViewportSize({ width: 1280, height: 800 });
+ await page.getByRole("button", { name: "라이트 모드로" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
+ await expect(page.locator(".vg-set__group").first()).toHaveAttribute("data-surface", "panel");
+ await page.screenshot({
+ path: path.join(SHOT_DIR, "settings__1280-reference-light.png"),
+ fullPage: true,
+ });
+ await page.getByRole("button", { name: "다크 모드로" }).click();
+ await expect(page.locator("html")).toHaveAttribute("data-theme", "dark");
});
});
diff --git a/apps/web/e2e/learner.spec.ts b/apps/web/e2e/learner.spec.ts
index 668f1e8..e1cd3d1 100644
--- a/apps/web/e2e/learner.spec.ts
+++ b/apps/web/e2e/learner.spec.ts
@@ -228,7 +228,10 @@ test.describe("learner app shell and session launcher", () => {
await expect(page).toHaveURL(new RegExp(`/learn/session/${persona.code}$`));
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible();
- await expectNoDocumentOverflow(page);
+ // 프리플라이트(목표 선택 포함)는 좁은 뷰포트에서 세로 스크롤을 허용한다 — 데스크톱만 무스크롤 계약.
+ if ((page.viewportSize()?.width ?? 0) > 1180) {
+ await expectNoDocumentOverflow(page);
+ }
await page.getByRole("button", { name: "회기 시작" }).click();
@@ -313,7 +316,9 @@ test.describe("learner app shell and session launcher", () => {
});
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
- await expectNoDocumentOverflow(page);
+ if ((page.viewportSize()?.width ?? 0) > 1180) {
+ await expectNoDocumentOverflow(page);
+ }
await expectNoHorizontalOverflow(page);
});
@@ -346,7 +351,9 @@ test.describe("learner app shell and session launcher", () => {
).toBeVisible();
await expect(page.getByText("카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
- await expectNoDocumentOverflow(page);
+ if ((page.viewportSize()?.width ?? 0) > 1180) {
+ await expectNoDocumentOverflow(page);
+ }
await expectNoHorizontalOverflow(page);
});
});
diff --git a/apps/web/e2e/public-admin-visual.spec.ts b/apps/web/e2e/public-admin-visual.spec.ts
new file mode 100644
index 0000000..fd93549
--- /dev/null
+++ b/apps/web/e2e/public-admin-visual.spec.ts
@@ -0,0 +1,223 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+import { expect, test } from "@playwright/test";
+
+const publicApiBase = process.env.E2E_PUBLIC_API_BASE ?? "https://api-vignette.chanpaca.net";
+const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "public-admin-visual");
+
+interface AuthMeResponse {
+ email?: string;
+ role?: string;
+ admin_access?: boolean;
+ super_admin?: boolean;
+}
+
+async function browserFetchJson(
+ page: import("@playwright/test").Page,
+ pathName: string,
+) {
+ return page.evaluate(
+ async ({ apiBase, apiPath }) => {
+ const response = await fetch(`${apiBase}${apiPath}`, {
+ credentials: "include",
+ headers: { accept: "application/json" },
+ });
+ const text = await response.text();
+ let json: unknown = null;
+ try {
+ json = text ? JSON.parse(text) : null;
+ } catch {
+ json = null;
+ }
+ return { status: response.status, ok: response.ok, text, json };
+ },
+ { apiBase: publicApiBase, apiPath: pathName },
+ ) as Promise<{ status: number; ok: boolean; text: string; json: T | null }>;
+}
+
+test.describe("public admin visual @public-auth", () => {
+ test("renders the public admin home with visible operational content", async ({
+ page,
+ }, testInfo) => {
+ if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
+ throw new Error(
+ "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
+ );
+ }
+
+ await fs.mkdir(SHOT_DIR, { recursive: true });
+ await page.setViewportSize({ width: 1440, height: 900 });
+
+ await page.goto("/admin", { waitUntil: "domcontentloaded" });
+ await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
+
+ const me = await browserFetchJson(page, "/auth/me");
+ expect(me.status, me.text).toBe(200);
+ expect(
+ me.json?.role === "admin" || me.json?.admin_access === true || me.json?.super_admin === true,
+ JSON.stringify(me.json),
+ ).toBeTruthy();
+
+ await expect(page).toHaveURL(/\/admin(?:$|[?#])/);
+ await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 });
+ await expect(page.locator(".vg-shell")).toBeVisible();
+ await expect(page.locator(".vg-nav").getByRole("link", { name: "운영 홈" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
+ await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible();
+ await expect(page.locator(".ad-kpi b")).toHaveCount(4);
+ await expect(page.locator("#vignette-boot-diagnostic")).toHaveCount(0);
+
+ const visualReport = await page.evaluate(() => {
+ const adminRoot = document.querySelector(".ad-root");
+ const main = document.querySelector("main");
+ const heading = Array.from(document.querySelectorAll("h1,h2,h3")).find(
+ (node) => node.textContent?.includes("현재 서비스 상태"),
+ );
+ const visibleNodes = Array.from(
+ document.querySelectorAll(
+ ".ad-root,.ad-head,.ad-kpi,.ad-panel,.ad-service,.vg-shell,.vg-nav,h1,h2,h3,p,a,button",
+ ),
+ ).filter((node) => {
+ const rect = node.getBoundingClientRect();
+ const style = getComputedStyle(node);
+ return (
+ rect.width > 4 &&
+ rect.height > 4 &&
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ style.opacity !== "0"
+ );
+ });
+ const rootRect = adminRoot?.getBoundingClientRect();
+ const mainRect = main?.getBoundingClientRect();
+ const headingRect = heading?.getBoundingClientRect();
+ return {
+ url: location.href,
+ bodyTextLength: document.body.innerText.trim().length,
+ visibleNodeCount: visibleNodes.length,
+ adminRootRect: rootRect
+ ? { x: rootRect.x, y: rootRect.y, width: rootRect.width, height: rootRect.height }
+ : null,
+ mainRect: mainRect
+ ? { x: mainRect.x, y: mainRect.y, width: mainRect.width, height: mainRect.height }
+ : null,
+ headingRect: headingRect
+ ? { x: headingRect.x, y: headingRect.y, width: headingRect.width, height: headingRect.height }
+ : null,
+ horizontalOverflow:
+ document.documentElement.scrollWidth - document.documentElement.clientWidth,
+ };
+ });
+
+ expect(visualReport.bodyTextLength, JSON.stringify(visualReport)).toBeGreaterThan(400);
+ expect(visualReport.visibleNodeCount, JSON.stringify(visualReport)).toBeGreaterThan(20);
+ expect(visualReport.adminRootRect?.width, JSON.stringify(visualReport)).toBeGreaterThan(900);
+ expect(visualReport.adminRootRect?.height, JSON.stringify(visualReport)).toBeGreaterThan(500);
+ expect(visualReport.mainRect?.width, JSON.stringify(visualReport)).toBeGreaterThan(800);
+ expect(visualReport.headingRect?.y, JSON.stringify(visualReport)).toBeGreaterThanOrEqual(0);
+ expect(visualReport.headingRect?.y, JSON.stringify(visualReport)).toBeLessThan(900);
+ expect(visualReport.horizontalOverflow, JSON.stringify(visualReport)).toBeLessThanOrEqual(1);
+
+ const shotPath = path.join(SHOT_DIR, `admin-home-${testInfo.project.name}.png`);
+ await page.screenshot({ path: shotPath, fullPage: true });
+ const shot = await fs.stat(shotPath);
+ expect(shot.size, `admin visual screenshot was too small: ${shotPath}`).toBeGreaterThan(
+ 80_000,
+ );
+ });
+
+ test("renders every public admin section without a silent blank pane", async ({ page }) => {
+ if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
+ throw new Error(
+ "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
+ );
+ }
+
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
+ page.on("pageerror", (error) => pageErrors.push(error.message));
+ page.on("console", (message) => {
+ if (message.type() === "error") consoleErrors.push(message.text());
+ });
+
+ const sections = [
+ { path: "/admin", heading: "현재 서비스 상태" },
+ { path: "/admin/users", heading: "가입 승인과 권한 관리" },
+ { path: "/admin/access", heading: "역할, 그룹, 접근 범위" },
+ { path: "/admin/tickets", heading: "사용자 문제 큐" },
+ ] as const;
+
+ for (const section of sections) {
+ await page.goto(section.path, { waitUntil: "domcontentloaded" });
+ await expect(page.locator(".ad-root")).toBeVisible({ timeout: 15_000 });
+ await expect(page.getByRole("heading", { name: section.heading })).toBeVisible();
+ await expect(page.locator(".ad-diagnostic")).toHaveCount(0);
+
+ const report = await page.evaluate(() => {
+ const root = document.querySelector(".ad-root");
+ const rect = root?.getBoundingClientRect();
+ const visibleNodes = root
+ ? Array.from(root.querySelectorAll("h1,h2,p,button,input,select,article,section"))
+ .filter((node) => {
+ const nodeRect = node.getBoundingClientRect();
+ const style = getComputedStyle(node);
+ return (
+ nodeRect.width > 1 &&
+ nodeRect.height > 1 &&
+ style.display !== "none" &&
+ style.visibility !== "hidden" &&
+ style.opacity !== "0"
+ );
+ }).length
+ : 0;
+ return {
+ textLength: root?.innerText.trim().length ?? 0,
+ visibleNodes,
+ width: rect?.width ?? 0,
+ height: rect?.height ?? 0,
+ scrollY: window.scrollY,
+ };
+ });
+
+ expect(report.textLength, JSON.stringify({ section, report })).toBeGreaterThan(120);
+ expect(report.visibleNodes, JSON.stringify({ section, report })).toBeGreaterThan(5);
+ expect(report.width, JSON.stringify({ section, report })).toBeGreaterThan(300);
+ expect(report.height, JSON.stringify({ section, report })).toBeGreaterThan(120);
+ expect(report.scrollY, JSON.stringify({ section, report })).toBe(0);
+ }
+
+ expect(pageErrors, JSON.stringify(pageErrors)).toEqual([]);
+ expect(consoleErrors, JSON.stringify(consoleErrors)).toEqual([]);
+ });
+
+ test("returns a restored admin tab to visible content after pageshow", async ({ page }) => {
+ if (!process.env.E2E_PUBLIC_STORAGE_STATE) {
+ throw new Error(
+ "Set E2E_PUBLIC_STORAGE_STATE to a storageState file captured after Google admin login.",
+ );
+ }
+
+ await page.goto("/admin", { waitUntil: "domcontentloaded" });
+ await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
+ await expect(page.locator(".ad-root")).toBeVisible();
+
+ const beforeRestore = await page.evaluate(() => {
+ document.documentElement.style.minHeight = "2200px";
+ document.body.style.minHeight = "2200px";
+ window.scrollTo(0, 900);
+ return window.scrollY;
+ });
+ expect(beforeRestore).toBeGreaterThan(0);
+
+ await page.evaluate(() => {
+ window.dispatchEvent(new PageTransitionEvent("pageshow", { persisted: true }));
+ });
+
+ await expect
+ .poll(() => page.evaluate(() => window.scrollY), { timeout: 5_000 })
+ .toBe(0);
+ await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeInViewport();
+ });
+});
diff --git a/apps/web/e2e/readiness.spec.ts b/apps/web/e2e/readiness.spec.ts
index cc66ffe..8428079 100644
--- a/apps/web/e2e/readiness.spec.ts
+++ b/apps/web/e2e/readiness.spec.ts
@@ -60,10 +60,16 @@ test.describe("production readiness gates", () => {
const statusRegion = page.getByRole("region", { name: "학습 현황" });
await expect(statusRegion.getByRole("button", { name: /진행 회기 0회/ })).toBeVisible();
await expect(statusRegion.getByRole("button", { name: /리뷰 대기 0건/ })).toBeVisible();
+ // 중간 폭 이하에서는 기록 패널이 '기록 · 리뷰' 탭 안에 있다.
+ const dashTabs = page.locator(".lh-tabs");
+ if (await dashTabs.isVisible().catch(() => false)) {
+ await dashTabs.locator("button", { hasText: "기록 · 리뷰" }).click();
+ }
await expect(page.getByText("최근 기록이 없습니다.")).toBeVisible();
- await expect(
- page.locator(".lh-head").getByRole("button", { name: "학습 대상 선택" }),
- ).toBeInViewport();
+ if (await dashTabs.isVisible().catch(() => false)) {
+ await dashTabs.locator("button", { hasText: "오늘의 회기" }).click();
+ }
+ await expect(page.getByRole("button", { name: "학습 대상 선택" })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
diff --git a/apps/web/e2e/session-mvp.spec.ts b/apps/web/e2e/session-mvp.spec.ts
index a7378cd..a32fd3a 100644
--- a/apps/web/e2e/session-mvp.spec.ts
+++ b/apps/web/e2e/session-mvp.spec.ts
@@ -669,9 +669,14 @@ test.describe("P1 MVP core loop", () => {
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
- await expect(page.getByText("내담자가 남긴 것")).toBeVisible();
- await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible();
await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible();
+ // ≤1180 폭에서는 리뷰가 가로 탭 — 마지막 내담자 반응은 피드백 탭에 있다.
+ const feedbackTab = page.locator(".sr-tabs button", { hasText: "피드백" });
+ if (await feedbackTab.isVisible().catch(() => false)) {
+ await feedbackTab.click();
+ }
+ await expect(page.getByText("마지막 내담자 반응")).toBeVisible();
+ await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible();
});
test("shows AI tutor coaching in coached mode after a completed turn", async ({ page }) => {
diff --git a/apps/web/e2e/session-persistence.spec.ts b/apps/web/e2e/session-persistence.spec.ts
index 57aa1cf..db3437f 100644
--- a/apps/web/e2e/session-persistence.spec.ts
+++ b/apps/web/e2e/session-persistence.spec.ts
@@ -529,6 +529,13 @@ async function evaluationState(page: Page, sessionId: string) {
return `pending:${body.status ?? "none"}:${body.durable ? "durable" : "cache"}:${loop}`;
}
+/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
+async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
+ const tabs = page.locator(".sr-tabs");
+ await tabs.waitFor({ state: "attached", timeout: 20_000 });
+ await tabs.locator("button", { hasText: label }).click();
+}
+
test.describe("session persistence", () => {
test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({
page,
@@ -781,7 +788,7 @@ test.describe("session persistence", () => {
});
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
- test.setTimeout(150_000);
+ test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
@@ -890,7 +897,7 @@ test.describe("session persistence", () => {
});
test("persists voice nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
- test.setTimeout(120_000);
+ test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
@@ -937,7 +944,9 @@ test.describe("session persistence", () => {
const timeout = window.setTimeout(() => {
ws.close();
finish(-1);
- }, 60_000);
+ // dev 엔진(Claude CLI 게이트웨이) 음성 턴이 90초를 넘기도 한다 — 게이트웨이
+ // GENERATE_TURN_TIMEOUT_SECONDS(300초)와 정합하게 150초까지 기다린다.
+ }, 150_000);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "audio_start", format: "webm" }));
@@ -1085,7 +1094,7 @@ test.describe("session persistence", () => {
reply: events.some((event) => event.type === "reply"),
errors: events.filter((event) => event.type === "error" || event.type === "degraded").length,
};
- }, { timeout: 60_000 })
+ }, { timeout: 90_000 })
.toEqual({ transcript: true, reply: true, errors: 0 });
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
@@ -1166,6 +1175,7 @@ test.describe("session persistence", () => {
).toBeGreaterThanOrEqual(2);
await page.goto(`/learn/session/${sessionId}/review`);
+ await openReviewTab(page, "워크시트");
const worksheetCard = page.locator(".sr-card--worksheet");
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("축어록 기반 자동 초안");
@@ -1188,10 +1198,13 @@ test.describe("session persistence", () => {
await signInAsTeacher(page);
await page.goto(`/teach/session/${sessionId}/review`);
+ await openReviewTab(page, "워크시트");
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("학습자 저장본 읽기 전용");
await expect(worksheetCard.locator(".sr-ws-input").first()).toHaveValue(savedWorksheetValue);
+ // 워크시트 검수 메모·수정요청은 교수자 검토 카드(피드백 탭)에 있다.
+ await openReviewTab(page, "피드백");
const teacherWorksheetNote = `보호요인 보강 요청 ${Date.now()}`;
await page.getByLabel("워크시트 검수 메모").fill(teacherWorksheetNote);
await page.getByRole("button", { name: "수정요청" }).click();
@@ -1226,6 +1239,7 @@ test.describe("session persistence", () => {
const sessionId = await createEndedSessionWithoutTurn(page);
await page.goto(`/learn/session/${sessionId}/review`);
+ await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
@@ -1272,6 +1286,7 @@ test.describe("session persistence", () => {
expect(byKey.get("training_satisfaction:post")).toBe(Number(targetValues[5]));
await page.reload();
+ await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("3/3 쌍")).toBeVisible();
for (let index = 0; index < targetValues.length; index += 1) {
diff --git a/apps/web/e2e/session-review.spec.ts b/apps/web/e2e/session-review.spec.ts
index 18de1e5..6d5c1af 100644
--- a/apps/web/e2e/session-review.spec.ts
+++ b/apps/web/e2e/session-review.spec.ts
@@ -97,63 +97,79 @@ async function createEndedSession(page: Page) {
return session.session_id;
}
+/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
+async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
+ // 탭 바는 리뷰 레이아웃이 로드된 뒤에만 DOM에 존재한다 — attach를 먼저 기다린다.
+ const tabs = page.locator(".sr-tabs");
+ await tabs.waitFor({ state: "attached", timeout: 10_000 }).catch(() => {});
+ const tab = tabs.locator("button", { hasText: label });
+ if (await tab.isVisible().catch(() => false)) {
+ await tab.click();
+ }
+}
+
async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-overview")).toBeVisible();
+ await expect(page.locator(".sr-tabs")).toBeVisible();
+
+ // 피드백 탭: 인사이트 흐름 + 평가 레일이 함께 도달 가능해야 한다.
+ await openReviewTab(page, "피드백");
await expect(page.locator(".sr-feedback")).toBeVisible();
await expect(page.locator(".sr-card--chart")).toBeVisible();
await expect(page.locator(".sr-card--flow")).toBeVisible();
await expect(page.locator(".sr-card--rubric")).toBeVisible();
- await expect(page.locator(".sr-card--transcript")).toBeVisible();
await expect(page.locator(".sr-card--prepost")).toBeVisible();
- const grid = await page.locator(".sr-cols").evaluate((el) => {
+ const insightsGrid = await page.locator(".sr-cols").evaluate((el) => {
const style = window.getComputedStyle(el);
return {
display: style.display,
areas: style.gridTemplateAreas,
+ columns: style.gridTemplateColumns.split(" ").filter(Boolean).length,
};
});
- expect(grid.display).toBe("grid");
- expect(grid.areas).toContain("overview");
- expect(grid.areas).toContain("feedback");
- expect(grid.areas).toContain("transcript");
- expect(grid.areas).not.toContain("teacher");
+ expect(insightsGrid.display).toBe("grid");
+ expect(insightsGrid.areas).toContain("insight");
+ expect(insightsGrid.areas).toContain("feedback");
+ expect(insightsGrid.areas).not.toContain("teacher");
+ // 전 폭 탭 계약: 활성 탭 하나가 전폭 단일 컬럼을 쓴다(인사이트 위 / 평가 아래).
+ expect(insightsGrid.columns).toBe(1);
+ const stackGeometry = await page.locator(".sr-cols").evaluate((el) => {
+ const insight = el.querySelector(".sr-left")?.getBoundingClientRect();
+ const feedback = el.querySelector(".sr-right")?.getBoundingClientRect();
+ return {
+ insightBottom: insight ? Math.ceil(insight.bottom) : 0,
+ feedbackTop: feedback ? Math.floor(feedback.top) : 0,
+ };
+ });
+ expect(stackGeometry.insightBottom).toBeLessThanOrEqual(stackGeometry.feedbackTop + 1);
+
+ // 워크시트 탭.
+ await openReviewTab(page, "워크시트");
+ await expect(page.locator(".sr-card--worksheet")).toBeVisible();
+
+ // 축어록 탭(기본)으로 복귀 — 축어록은 전폭 단일 컬럼.
+ await openReviewTab(page, "축어록");
+ await expect(page.locator(".sr-card--transcript")).toBeVisible();
+ const transcriptAreas = await page
+ .locator(".sr-cols")
+ .evaluate((el) => window.getComputedStyle(el).gridTemplateAreas);
+ expect(transcriptAreas).toContain("transcript");
await expect(page.locator(".sr-overview")).toBeInViewport();
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
- const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => {
- const root = gridEl.closest(".sr-root");
- const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect();
- const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect();
- const rubric = gridEl.querySelector(".sr-card--rubric")?.getBoundingClientRect();
- const prepost = gridEl.querySelector(".sr-card--prepost")?.getBoundingClientRect();
- const worksheet = gridEl.querySelector(".sr-card--worksheet")?.getBoundingClientRect();
- const columns = window
- .getComputedStyle(gridEl)
- .gridTemplateColumns.split(" ")
- .filter(Boolean).length;
+ // 리뷰 요약은 본문 그리드 위의 전폭 밴드다(도킹/스크롤 부유 금지).
+ const band = await page.locator(".sr-root").evaluate((rootEl) => {
+ const overview = rootEl.querySelector(".sr-overview")?.getBoundingClientRect();
+ const gridRect = rootEl.querySelector(".sr-cols")?.getBoundingClientRect();
return {
- isEmpty: root?.classList.contains("sr-root--empty") ?? false,
- columns,
- overviewLeft: overview ? Math.floor(overview.left) : 0,
- overviewRight: overview ? Math.ceil(overview.right) : 0,
- transcriptLeft: transcript ? Math.floor(transcript.left) : 0,
- transcriptRight: transcript ? Math.ceil(transcript.right) : 0,
- rubricLeft: rubric ? Math.floor(rubric.left) : 0,
- prepostLeft: prepost ? Math.floor(prepost.left) : 0,
- worksheetLeft: worksheet ? Math.floor(worksheet.left) : 0,
+ overviewBottom: overview ? Math.ceil(overview.bottom) : 0,
+ gridTop: gridRect ? Math.floor(gridRect.top) : 0,
};
});
- if (!railGeometry.isEmpty && railGeometry.columns === 2) {
- expect(railGeometry.transcriptRight).toBeLessThanOrEqual(railGeometry.overviewLeft);
- expect(railGeometry.rubricLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
- expect(railGeometry.prepostLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
- expect(railGeometry.worksheetLeft).toBeGreaterThanOrEqual(railGeometry.overviewLeft);
- } else if (railGeometry.columns >= 3) {
- expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft);
- }
+ expect(band.overviewBottom).toBeLessThanOrEqual(band.gridTop + 1);
}
test.describe("session review", () => {
@@ -186,6 +202,7 @@ test.describe("session review", () => {
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
+ await openReviewTab(page, "피드백");
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
});
@@ -221,6 +238,7 @@ test.describe("session review", () => {
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
+ await openReviewTab(page, "피드백");
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await expect(page.locator(".sr-card--prepost")).toHaveCount(0);
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
@@ -233,6 +251,7 @@ test.describe("session review", () => {
await page.goto(`/teach/session/${sessionId}/review`);
+ await openReviewTab(page, "피드백");
await expect(page.getByText("AI 평가 범위", { exact: true })).toBeVisible();
await expect(page.getByText("현재 구현", { exact: true })).toBeVisible();
await expect(page.getByText(/fast-loop.*deep-loop/)).toBeVisible();
@@ -348,6 +367,7 @@ test.describe("session review", () => {
await page.goto(`/teach/session/${sessionId}/review`);
+ await openReviewTab(page, "피드백");
await expect(page.getByText("평가 대기", { exact: true })).toBeVisible();
await expect(page.getByText("평가 AI가 저장된 축어록을 분석 중입니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
@@ -413,6 +433,7 @@ test.describe("session review", () => {
});
await page.goto(`/teach/session/${sessionId}/review`);
+ await openReviewTab(page, "피드백");
await expect(page.getByText("평가 실패")).toBeVisible();
await expect(
page.getByText("저장된 축어록은 확인했지만 deep-loop 평가 AI 산출물을 표시하지 못했습니다. AI 평가 재시도가 필요합니다."),
@@ -507,6 +528,7 @@ test.describe("session review", () => {
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("축어록 없음")).toBeVisible();
+ await openReviewTab(page, "피드백");
await expect(page.getByText("감정 타임라인 대기")).toBeVisible();
await expect(page.getByText("개선점 대기")).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeDisabled();
@@ -547,9 +569,12 @@ test.describe("session review", () => {
await expect(page).toHaveURL(new RegExp(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review$`));
await expect(page.getByText("관계 신호가 안정적으로 회복된 회기")).toBeVisible();
+ await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
+ await expect(page.getByText("감정 반영").first()).toBeVisible();
+ await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
+ await openReviewTab(page, "피드백");
await expect(page.getByText("감정 밸런스 타임라인")).toBeVisible();
await expect(page.getByText("회기 흐름")).toBeVisible();
- await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expect(page.getByText("기법 사용 분포")).toBeVisible();
await expect(page.getByText("파일럿 증거 원장")).toBeVisible();
await expect(page.getByText("1/3 쌍")).toBeVisible();
@@ -569,13 +594,12 @@ test.describe("session review", () => {
"수련만족도 사후 점수",
]);
await expect(page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다.")).toBeVisible();
- await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
- await expect(page.getByText("감정 반영").first()).toBeVisible();
- await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
await expect(page.getByText("다음에 시도할 문장")).toBeVisible();
await expect(page.getByText("방어를 낮춘 감정 반영")).toBeVisible();
await expect(page.getByText("개입 전 준비도 확인")).toBeVisible();
await expect(page.locator(".sr-chart__svg")).toBeVisible();
+ await openReviewTab(page, "워크시트");
+ await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expect(page.locator(".sr-ws-input").first()).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeEnabled();
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeEnabled();
@@ -624,6 +648,7 @@ test.describe("session review", () => {
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
);
await expect(transcript).not.toContainText("[NAME]");
+ await openReviewTab(page, "워크시트");
await expect(page.locator(".sr-ws-evidence").filter({ hasText: "[NAME]" })).toBeVisible();
});
@@ -633,6 +658,8 @@ test.describe("session review", () => {
await routePrepostMeasures(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
+ await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
+ await openReviewTab(page, "피드백");
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
const prepostInputs = page.locator(".sr-card--prepost input");
diff --git a/apps/web/e2e/teacher.spec.ts b/apps/web/e2e/teacher.spec.ts
index 82c666f..efd8b3c 100644
--- a/apps/web/e2e/teacher.spec.ts
+++ b/apps/web/e2e/teacher.spec.ts
@@ -184,6 +184,13 @@ function teacherReviewResponse(sessionId: string) {
};
}
+/** 리뷰는 모든 폭에서 가로 탭으로 영역을 전환한다. */
+async function openReviewTab(page: Page, label: "축어록" | "피드백" | "워크시트") {
+ const tabs = page.locator(".sr-tabs");
+ await tabs.waitFor({ state: "attached", timeout: 20_000 });
+ await tabs.locator("button", { hasText: label }).click();
+}
+
test.describe("teacher console", () => {
test("lets a teacher approve a pending persona review from the console @single-run", async ({
page,
@@ -317,7 +324,7 @@ test.describe("teacher console", () => {
});
});
- await page.goto("/teach/personas");
+ await page.goto("/teach/personas?view=personas");
const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P1" });
await expect(row).toBeVisible();
await row.getByRole("button", { name: "수정" }).click();
@@ -373,7 +380,7 @@ test.describe("teacher console", () => {
});
});
- await page.goto("/teach/personas");
+ await page.goto("/teach/personas?view=personas&mode=create&step=edit");
await page.getByLabel("표시 이름").fill("항목형 페르소나");
await page.getByRole("tab", { name: "임상" }).click();
@@ -387,7 +394,7 @@ test.describe("teacher console", () => {
const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" });
await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정");
- await page.getByRole("button", { name: "초안 저장" }).click();
+ await page.getByRole("button", { name: "중간 저장" }).click();
await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible();
expect(savedPayload).toBeTruthy();
@@ -408,7 +415,7 @@ test.describe("teacher console", () => {
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
});
- await page.goto("/teach/personas");
+ await page.goto("/teach/personas?view=personas&mode=create&step=edit");
await page.getByLabel("표시 이름").fill("프롬프트 검토 페르소나");
await page.getByRole("tab", { name: "임상" }).click();
const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" });
@@ -484,7 +491,7 @@ test.describe("teacher console", () => {
});
});
- await page.goto("/teach/personas");
+ await page.goto("/teach/personas?view=personas");
const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P7" });
await expect(row).toBeVisible();
await expectVisibleButtonsFit(page, ".ps-approved-row__actions .vg-btn", "approved persona actions");
@@ -492,11 +499,104 @@ test.describe("teacher console", () => {
await row.getByRole("button", { name: "삭제" }).click();
await expect(page.getByText("P7 페르소나를 보관 처리했습니다.")).toBeVisible();
- await expect(page.getByText("공개 목록 없음")).toBeVisible();
+ await expect(page.getByText("조건에 맞는 페르소나 없음")).toBeVisible();
expect(archived).toBeTruthy();
await expectNoHorizontalOverflow(page);
});
+ test("navigates persona dashboard, catalog, table, detail, and review queue @single-run", async ({ page }) => {
+ const approvedPersona = {
+ persona_id: "00000000-0000-0000-0000-000000000721",
+ code: "P1",
+ version: 3,
+ status: "approved",
+ display_name: "서연(가명) · 고2 · 우울/자살사고",
+ difficulty: "hard",
+ theory_target: ["humanistic"],
+ demographics: { age_band: "F-teen" },
+ presenting_summary: "최근 무기력과 관계 단절을 호소합니다.",
+ source: "database",
+ degraded: false,
+ voice_preset: null,
+ };
+ const reviewPersona = {
+ persona_id: "00000000-0000-0000-0000-000000000722",
+ code: "P12",
+ version: 1,
+ status: "review",
+ display_name: "직장 적응 훈련 페르소나",
+ difficulty: "moderate",
+ theory_target: ["cbt"],
+ source_provenance: "교수자 작성 초안",
+ is_synthetic: true,
+ created_at: "2026-07-15T08:00:00Z",
+ approved_at: null,
+ };
+ await signInAsTeacher(page);
+ await page.route("**/api/personas", (route) => {
+ if (route.request().method() !== "GET") return route.fallback();
+ return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([approvedPersona]) });
+ });
+ await page.route("**/api/personas/review", (route) =>
+ route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([reviewPersona]) }),
+ );
+ await page.route("**/api/teacher/dashboard", (route) =>
+ route.fulfill({
+ status: 200,
+ contentType: "application/json",
+ body: JSON.stringify({
+ source: "database",
+ cohort_label: "E2E cohort",
+ total_learners: 1,
+ active_sessions: 1,
+ ended_sessions: 1,
+ learner_growth: [{
+ learner_id: "00000000-0000-0000-0000-000000000723",
+ learner_label: "학습자 A",
+ sessions: 2,
+ ended_sessions: 1,
+ latest_at: "2026-07-15T08:20:00Z",
+ avg_score: 0.75,
+ avg_rapport: 0.4,
+ trend: "up",
+ points: [{ session_id: "persona-session-1", session_no: 1, persona_code: "P1", stage: "탐색", started_at: "2026-07-15T08:00:00Z", ended_at: "2026-07-15T08:20:00Z", score: 0.75, rapport: 0.4, technique_count: 3, watch_count: 0 }],
+ }],
+ safety_alerts: [],
+ pending_reviews: [],
+ recent_sessions: [{ session_id: "persona-session-2", learner_id: "00000000-0000-0000-0000-000000000723", learner_label: "학습자 A", persona_code: "P1", persona_name: approvedPersona.display_name, session_no: 2, status: "active", stage: "탐색", turn_count: 4, learner_turn_count: 2, client_turn_count: 2, started_at: "2026-07-15T08:30:00Z", ended_at: null }],
+ message: "페르소나 운영 fixture",
+ }),
+ }),
+ );
+
+ await page.goto("/teach/personas");
+ await expect(page.getByRole("heading", { name: "페르소나 운영" })).toBeVisible();
+ await expect(page.getByText("상담 수행 점수")).toBeVisible();
+
+ await page.getByRole("button", { name: /카탈로그/ }).first().click();
+ await expect(page.getByRole("heading", { name: "페르소나 카탈로그" })).toBeVisible();
+ await expect(page.getByText("학습자가 회기를 시작할 때 선택하는 승인본 모음입니다.")).toBeVisible();
+
+ await page.getByRole("button", { name: /페르소나/ }).first().click();
+ await expect(page.getByRole("button", { name: "새로운 페르소나 만들기" })).toBeVisible();
+ const approvedRow = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P1" });
+ await expect(approvedRow).toBeVisible();
+ await approvedRow.getByRole("button", { name: "상세" }).click();
+ await expect(page.getByRole("tab", { name: "소개·정보" })).toBeVisible();
+ await page.getByRole("tab", { name: "학습 현황" }).click();
+ await expect(page.getByText("75%").first()).toBeVisible();
+ await page.getByRole("button", { name: "목록으로" }).click();
+ await page.getByRole("tab", { name: /검수 현황/ }).click();
+ await expect(page.getByText("직장 적응 훈련 페르소나")).toBeVisible();
+ await page.getByRole("button", { name: "새로운 페르소나 만들기" }).click();
+ await expect(page.getByRole("navigation", { name: "페르소나 작성 단계" })).toBeVisible();
+ await page.getByRole("button", { name: /설정 임상·회기·말투 조정/ }).click();
+ await expect(page.getByRole("tab", { name: "개요" })).toBeVisible();
+ await page.getByRole("button", { name: "이전" }).click();
+ await expect(page).toHaveURL(/step=generate/);
+ await expectNoHorizontalOverflow(page);
+ });
+
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
@@ -768,9 +868,11 @@ test.describe("teacher console", () => {
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
- await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
+ await openReviewTab(page, "워크시트");
await expect(page.getByText("축어록 자동 초안 읽기 전용")).toBeVisible();
await expect(page.getByText("검토 전용")).toBeVisible();
+ await openReviewTab(page, "피드백");
+ await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await page.getByLabel("검토 메모").fill("다음 회기에서 감정 반영을 먼저 확인");
await page.getByRole("button", { name: "검토 완료" }).click();
await expect(page.getByText("완료 시각 2026-06-27T10:00:00Z")).toBeVisible();
@@ -953,6 +1055,7 @@ test.describe("teacher console", () => {
await expect(page).toHaveURL(new RegExp(`/teach/session/${activeSession.session_id}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("진행 중", { exact: true })).toBeVisible();
+ await openReviewTab(page, "피드백");
await expect(page.getByText("평가 대기", { exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
@@ -1022,6 +1125,7 @@ test.describe("teacher console", () => {
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("진행 중", { exact: true })).toBeVisible();
+ await openReviewTab(page, "워크시트");
await expect(page.getByText("검토 전용")).toBeVisible();
await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
diff --git a/apps/web/knip.json b/apps/web/knip.json
new file mode 100644
index 0000000..c73e03f
--- /dev/null
+++ b/apps/web/knip.json
@@ -0,0 +1,19 @@
+{
+ "$schema": "https://unpkg.com/knip@6/schema.json",
+ "entry": [
+ "functions/**/*.js",
+ "public/worklets/**/*.js",
+ "scripts/*.mjs",
+ "e2e/**/*.spec.ts"
+ ],
+ "project": [
+ "src/**/*.{ts,tsx}",
+ "functions/**/*.js",
+ "public/worklets/**/*.js",
+ "scripts/*.mjs",
+ "e2e/**/*.ts"
+ ],
+ "ignoreFiles": ["src/components/avatar/RasterBust.tsx"],
+ "ignoreDependencies": ["openapi-typescript"],
+ "ignoreBinaries": ["taskkill"]
+}
diff --git a/apps/web/package-lock.json b/apps/web/package-lock.json
index 72efa54..bff0273 100644
--- a/apps/web/package-lock.json
+++ b/apps/web/package-lock.json
@@ -8,6 +8,7 @@
"name": "@vignette/web",
"version": "0.1.0",
"dependencies": {
+ "@tanstack/react-table": "^8.21.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"
@@ -17,8 +18,10 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
+ "esbuild": "^0.25.12",
+ "jscpd": "5.0.12",
+ "knip": "^6.26.0",
"openapi-typescript": "^7.13.0",
- "puppeteer-core": "^25.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
@@ -305,6 +308,40 @@
"node": ">=6.9.0"
}
},
+ "node_modules/@emnapi/core": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
+ "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/runtime": {
+ "version": "1.11.1",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz",
+ "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@emnapi/wasi-threads": {
+ "version": "1.2.2",
+ "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
+ "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@esbuild/aix-ppc64": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz",
@@ -797,6 +834,719 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
+ "node_modules/@napi-rs/wasm-runtime": {
+ "version": "1.1.6",
+ "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
+ "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@tybys/wasm-util": "^0.10.3"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/Brooooooklyn"
+ },
+ "peerDependencies": {
+ "@emnapi/core": "^1.7.1",
+ "@emnapi/runtime": "^1.7.1"
+ }
+ },
+ "node_modules/@oxc-parser/binding-android-arm-eabi": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm-eabi/-/binding-android-arm-eabi-0.137.0.tgz",
+ "integrity": "sha512-KDs+0VPdEmasOkpuJHW9V5WCF+cvYdMQv2Jd+aJXt+cxIx12NToRQRbXaRwUEDsZw+/jMk81Ve8ZFbjUkJTOwA==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-android-arm64": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-android-arm64/-/binding-android-arm64-0.137.0.tgz",
+ "integrity": "sha512-WhALNzfy3x/RfC6bsqX+csavuUY0yHHE7XfgPE5M542uhoBZUUoGTPG+nkMbGoG4+gcfss5s7urMyn5QBHu0sw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-arm64": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-arm64/-/binding-darwin-arm64-0.137.0.tgz",
+ "integrity": "sha512-bFPr5hgmNMOMoyPTGtdsK4Ug21RovIPojRMgDDhSp1LtCnc/DkLwGONKjgRjszg677RlGnkYSviQ8hHaUPOVYA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-darwin-x64": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-darwin-x64/-/binding-darwin-x64-0.137.0.tgz",
+ "integrity": "sha512-CL5dMm1asqXIDZHg14FLxj3Mc36w8PI7xCWh1uA4is6z8g2XrIILoTcQYOxDbwzuk34RDPX5IAGUxZr6LA9KAg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-freebsd-x64": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-freebsd-x64/-/binding-freebsd-x64-0.137.0.tgz",
+ "integrity": "sha512-79h8rYGnSlKPGWo7mHr2ixO6ea7aW8B0CT965SZ8SLbNnCOH5aOYBTeVXUY6eMvEaiLyWr8Skuiugr5pDYgLGw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-gnueabihf": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.137.0.tgz",
+ "integrity": "sha512-ASgmlSimhGyr0lksgVIo6hibz1obnDq4qJbiMX/AzltfgPnanRrzG1Q+23g8ljOHOjv6dsznkUuCYL3gg0sY1Q==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm-musleabihf": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.137.0.tgz",
+ "integrity": "sha512-AU2J9aa22Sx32wRGnDjybOU9TQXXQUud5sdUi+ZB0XxwM8aToWLweV+yA0wlQm0yIUVqljquqoHCYEq9II8gJQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-gnu": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.137.0.tgz",
+ "integrity": "sha512-GdEtiG89yMr7XkUGxifgodXEEm2f+xW2f9CpDjlgAnBOwhTmrpQMvhOGobLVKUyzf/qHBXW16smk5zbF3nZU6w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-arm64-musl": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.137.0.tgz",
+ "integrity": "sha512-EGJ+Bs8iXx8KBH8DQ5BLoEm5lnHaYjlh4/8j8vFhrr/6z4tqONy5BZDzLpKmmNWlN6Hlc5r8YOuBVHqZ9vRFEQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-ppc64-gnu": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.137.0.tgz",
+ "integrity": "sha512-vzFUQENy/fnbSe5DZWovq6tIBc1uhuMztanSW6rz1e9WdQE4gHwYuD7ZII6JnrJifd1R3RSoqiZbgRFlVL2tYQ==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-gnu": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.137.0.tgz",
+ "integrity": "sha512-SfVI14HBQs9gtLcUD5hTt5hsNbdrqSUNg9S8muN+LhVQ5nf1WwH3hAoK6B9NKgdYgWAQSXFXGiiBedQ4r/BKuw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-riscv64-musl": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.137.0.tgz",
+ "integrity": "sha512-e7Ppy4FCIFNQxT/ikSeIWFoQ0l+N9vgtRBtLcyZXeolTzApyVoPqEXsYPrcdM/9i0Bwk8knvYd37vaEMxHyi6g==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-s390x-gnu": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.137.0.tgz",
+ "integrity": "sha512-Bho5qFwdhqsIFR7gipYEUlqvi3SRrY8sugxXig380MIaakBB1PyU9+7dBiBVScfImTNWhijUxdBwqrprGdq5WA==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-gnu": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.137.0.tgz",
+ "integrity": "sha512-36mGWtg7PyFzjJwGDkH6/F4o2nIDEoKXLPr/X/lwqklkomQwJJt1I5GJVmGhovUEmgPK5WAeAZMqlFCehwiy9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-linux-x64-musl": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-linux-x64-musl/-/binding-linux-x64-musl-0.137.0.tgz",
+ "integrity": "sha512-/Jqx6+N7A44n2BdvUr7pXhVr2vFjs6WGH3unZRczwrfiH0H1zY0QwKQMG/dtRiTlKGDKGukznPT8lx84/oEsZg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-openharmony-arm64": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-openharmony-arm64/-/binding-openharmony-arm64-0.137.0.tgz",
+ "integrity": "sha512-9Uj0qHNNl+OgT1UTGwF7ixIXU6T1u2SbMidmgPy/h1h/fl2gRS6YpAxxY1gwHofcWjoTwkoMFd8xs5Vuj6GOFA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-wasm32-wasi": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-wasm32-wasi/-/binding-wasm32-wasi-0.137.0.tgz",
+ "integrity": "sha512-gW2vfkytNGgMVADiuzdvOfw0mWG9za20F/1fCJsif5aBMAvWJTSbpIXbIe0XkOe0VENk+PadpQ7cZgUy2sUJcA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.1",
+ "@emnapi/runtime": "1.11.1",
+ "@napi-rs/wasm-runtime": "^1.1.5"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-arm64-msvc": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.137.0.tgz",
+ "integrity": "sha512-x+pFANF0yL5uK/6T7lu6SlR5qid6sp//eZXKLq5iNsIE+EQg6EaS8/wsW7E91nXXjpnPhSoMOHXShSVhGRdn8w==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-ia32-msvc": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.137.0.tgz",
+ "integrity": "sha512-sQUqym80PFi6McRsIqfJrSu2JrSClEZIXXD+/FjAFoULEKzOPsldIdFBG96xdX8aVMzCNQ9792FPx3MfkEIrFA==",
+ "cpu": [
+ "ia32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-parser/binding-win32-x64-msvc": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-parser/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.137.0.tgz",
+ "integrity": "sha512-2AsevxlvNN4WKxpEn3RtqD5zbqMaXF+T7JXblsP4gVuY+vC9dXS4ED/PwfRCliFqoeisYS3Iro4DHzxr0TEvVA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ],
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/@oxc-project/types": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz",
+ "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-android-arm-eabi": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm-eabi/-/binding-android-arm-eabi-11.21.3.tgz",
+ "integrity": "sha512-eNU11A2WNizh04v3uyaJCootrHIaS0B9aHYXvAvVnPNk4xYSjMUjHnhQ6dewPN2MRYDskV85d1N0Aw0WNWhcyg==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-android-arm64": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-android-arm64/-/binding-android-arm64-11.21.3.tgz",
+ "integrity": "sha512-8Q+ZjTLvn2dIcWsrmhdrEihm7q+ag/k+mkry7Z+t0QbbHaVxXQfvH9AewyVMh/WrpEKhQ3DDgx9fYbqeCpeOEw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "android"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-arm64": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-arm64/-/binding-darwin-arm64-11.21.3.tgz",
+ "integrity": "sha512-wkh0qKZGHXVUDxFw3oA1TXnU2BDYY/r775oJflGeIr8uDPPoN2pk8gijQIzYRT6hoql/lg3+Tx/SaTn9e2/aGg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-darwin-x64": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-darwin-x64/-/binding-darwin-x64-11.21.3.tgz",
+ "integrity": "sha512-HbNc23FAQYbuyDV2vBWMez4u4mrsm5RAkniGZAWqr6lYZ3N4beeqIb776jzwRl8qL2zRhHVXpUj97X0QgogVzg==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-freebsd-x64": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-freebsd-x64/-/binding-freebsd-x64-11.21.3.tgz",
+ "integrity": "sha512-K6xNsTUPEUdfrn0+kbMq5nOUB5w1C5pavPQngt4TM2FpN91lP0PBe2srSpamb4d69O7h86oAi/qWX/kZNRSjkw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "freebsd"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-gnueabihf": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-11.21.3.tgz",
+ "integrity": "sha512-VcFmOpcpWX1zoEy8M58tR2M9YxM+Z9RuQhqAx5q0CTmrruaP7Gveejg75hzd/5sg5nk9G3aLALEa3hE2FsmmTQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm-musleabihf": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-11.21.3.tgz",
+ "integrity": "sha512-quVoxFLBy43hWaQbbDtQNRwAX5vX76mv7n64icAtQcJ3eNgVeblqmkupF/hAneNthdqSlnd1sTjb3aQSaDPaCQ==",
+ "cpu": [
+ "arm"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-gnu": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-11.21.3.tgz",
+ "integrity": "sha512-X0AqNZgcD07Q4V3RDK18/vYOj/HQT/FnmEFGYS2jTWqY7JO13ryE3TEs3eAIgUJhBnNkpEaiXqz3VK8M7qQhWQ==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-arm64-musl": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-arm64-musl/-/binding-linux-arm64-musl-11.21.3.tgz",
+ "integrity": "sha512-YkaQnaKYdbuaXvRt5Qd0GpbihzVnyfR6z1SpYfIUC6RTu4NF7lDKPjVkYb+jRI2gedVO2rVpN35Y6akG6ud4Lw==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-ppc64-gnu": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-11.21.3.tgz",
+ "integrity": "sha512-gB9HwhrPiFqUzDeEq+y/CgAijz1YdI6BnXz5GaH2Pa9cWdutchlkGFAiAuGb/PjVQpiK6NFKzFuztxrweoit7A==",
+ "cpu": [
+ "ppc64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-gnu": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-11.21.3.tgz",
+ "integrity": "sha512-zjDWBlYk8QGv0H8dsPUWqkfjYIIjG2TvspGkzXL0eImbgxtZorA/klKeHyolevoT3Kvbi+1iMr9Lhrh7jf54Og==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-riscv64-musl": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-11.21.3.tgz",
+ "integrity": "sha512-4UfsQvacV388y1zpXL7C1x1FNYaV52JtuNRiuzrfQA2z1z6ElVrsidkGsrvQ5EgeSq1Pj7kaKqrgGkvFuxJ/tw==",
+ "cpu": [
+ "riscv64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-s390x-gnu": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-11.21.3.tgz",
+ "integrity": "sha512-b5uH+HKH0MP5mNBYaK75SKsJbw52URqrx2LavYdq6wb0l3ExAG5niYRP9DWUNHdKilpaBVM2bXk9HNWrH3ew7Q==",
+ "cpu": [
+ "s390x"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-gnu": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-gnu/-/binding-linux-x64-gnu-11.21.3.tgz",
+ "integrity": "sha512-PjYlmilBpNRh2ntXNYAK3Am5w/nPfEpnU/96iNx7CI8EzAn12J4JRiec63wHJTH31nLoCNxBg/829pN+3CfG3Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-linux-x64-musl": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-linux-x64-musl/-/binding-linux-x64-musl-11.21.3.tgz",
+ "integrity": "sha512-QTBAb7JuHlZ7JUEyM8UiQi2f7m/L4swBhP2TNpYIDc9Wp/wRw1G/8sl6i13aIzQAXH7LKIm294LeOHd0lQR8zA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-openharmony-arm64": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-openharmony-arm64/-/binding-openharmony-arm64-11.21.3.tgz",
+ "integrity": "sha512-4j1DFwjwv36ec9kds0jU/ucQ5Ha4ERO/H95BxR5JFf0kqUUAJ1kwII7XhTc1vZrkdJkvLGC9Q2MbpObpum8RBg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "openharmony"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-wasm32-wasi/-/binding-wasm32-wasi-11.21.3.tgz",
+ "integrity": "sha512-i8oluoel5kru/j1WNrjmQSiA3GQ7wvIYVR1IwIoZtKogAhya2iub+ZKIeSIkcJOrnzQ18Tzl/F+kL3fYOxZLvA==",
+ "cpu": [
+ "wasm32"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/core": "1.11.0",
+ "@emnapi/runtime": "1.11.0",
+ "@napi-rs/wasm-runtime": "^1.1.5"
+ },
+ "engines": {
+ "node": ">=14.0.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/core": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.0.tgz",
+ "integrity": "sha512-l9Oo58x0HOP5znGzVhYW9U3e5wVuA4LAZU2AGezTmkhO1CgQRFDhDg4nneHsu/t3WniXg9QrG2nIXL/ZS8ln8Q==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "@emnapi/wasi-threads": "1.2.2",
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-wasm32-wasi/node_modules/@emnapi/runtime": {
+ "version": "1.11.0",
+ "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.0.tgz",
+ "integrity": "sha512-55coeOFKHv1ywEcUXJtWU5f+Jr/W5tZDvZig8DLKSwUN1JpROQ4rk/SNOQiFWmaR/VKF4zuFyW1B8JduOSv6Pg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
+ "node_modules/@oxc-resolver/binding-win32-arm64-msvc": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-11.21.3.tgz",
+ "integrity": "sha512-M/8dw8dD6aOs+NlPJax401CZB9I7Aut84isQLgALGGwke4Afvw+/7yYhZb94yXf6t2sPLhQLmSmtSV+2FhsOWg==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
+ "node_modules/@oxc-resolver/binding-win32-x64-msvc": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/@oxc-resolver/binding-win32-x64-msvc/-/binding-win32-x64-msvc-11.21.3.tgz",
+ "integrity": "sha512-H7BCt/VnS9hnmMp42eGhZ99izSCRvlnWwy/N71K1/J8QoExwY4262Z8QiEkMDtduRJrztayDxETTckmUuAVL9Q==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/@playwright/test": {
"version": "1.61.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.1.tgz",
@@ -813,31 +1563,6 @@
"node": ">=18"
}
},
- "node_modules/@puppeteer/browsers": {
- "version": "3.0.5",
- "resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.0.5.tgz",
- "integrity": "sha512-xYXNuEQmHNIPWWcbL/skf2KF7seyp7c1xmKFRk3wmdFx7VwBsKVrtOLKs8ecaezsKPsWeF1YsgwIiElAscaryA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "modern-tar": "^0.7.6",
- "yargs": "^18.0.0"
- },
- "bin": {
- "browsers": "lib/main-cli.js"
- },
- "engines": {
- "node": ">=22.12.0"
- },
- "peerDependencies": {
- "proxy-agent": ">=8.0.1"
- },
- "peerDependenciesMeta": {
- "proxy-agent": {
- "optional": true
- }
- }
- },
"node_modules/@redocly/ajv": {
"version": "8.11.2",
"resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz",
@@ -1250,6 +1975,50 @@
"win32"
]
},
+ "node_modules/@tanstack/react-table": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/react-table/-/react-table-8.21.3.tgz",
+ "integrity": "sha512-5nNMTSETP4ykGegmVkhjcS8tTLW6Vl4axfEGQN3v0zdHYbK4UfoqfPChclTrJ4EoK9QynqAu9oUf8VEmrpZ5Ww==",
+ "license": "MIT",
+ "dependencies": {
+ "@tanstack/table-core": "8.21.3"
+ },
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "react": ">=16.8",
+ "react-dom": ">=16.8"
+ }
+ },
+ "node_modules/@tanstack/table-core": {
+ "version": "8.21.3",
+ "resolved": "https://registry.npmjs.org/@tanstack/table-core/-/table-core-8.21.3.tgz",
+ "integrity": "sha512-ldZXEhOBb8Is7xLs01fR3YEc3DERiz5silj8tnGkFZytt1abEvl/GhUmCE0PMLaMPTa3Jk4HbKmRlHmu+gCftg==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=12"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tybys/wasm-util": {
+ "version": "0.10.3",
+ "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz",
+ "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==",
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "dependencies": {
+ "tslib": "^2.4.0"
+ }
+ },
"node_modules/@types/babel__core": {
"version": "7.20.5",
"resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz",
@@ -1363,32 +2132,6 @@
"node": ">=6"
}
},
- "node_modules/ansi-regex": {
- "version": "6.2.2",
- "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
- "integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-regex?sponsor=1"
- }
- },
- "node_modules/ansi-styles": {
- "version": "6.2.3",
- "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
- "integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/ansi-styles?sponsor=1"
- }
- },
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
@@ -1488,38 +2231,6 @@
"dev": true,
"license": "MIT"
},
- "node_modules/chromium-bidi": {
- "version": "16.0.1",
- "resolved": "https://registry.npmjs.org/chromium-bidi/-/chromium-bidi-16.0.1.tgz",
- "integrity": "sha512-J63PGu/9PpeCwLIcKYyzWP6yaVL5pxuBc0shlYCYM8BaAkmlwiQboXO1iNbOgSDbVklEyYFfNEcHD8oOAWacUA==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "mitt": "^3.0.1",
- "zod": "^3.24.1"
- },
- "engines": {
- "node": ">=20.19.0 <22.0.0 || >=22.12.0"
- },
- "peerDependencies": {
- "devtools-protocol": "*"
- }
- },
- "node_modules/cliui": {
- "version": "9.0.1",
- "resolved": "https://registry.npmjs.org/cliui/-/cliui-9.0.1.tgz",
- "integrity": "sha512-k7ndgKhwoQveBL+/1tqGJYNz097I7WOvwbmmU2AR5+magtbjPWQTS1C5vzGkBC8Ym8UWRzfKUzUUqFLypY4Q+w==",
- "dev": true,
- "license": "ISC",
- "dependencies": {
- "string-width": "^7.2.0",
- "strip-ansi": "^7.1.0",
- "wrap-ansi": "^9.0.0"
- },
- "engines": {
- "node": ">=20"
- }
- },
"node_modules/colorette": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colorette/-/colorette-1.4.0.tgz",
@@ -1559,13 +2270,6 @@
}
}
},
- "node_modules/devtools-protocol": {
- "version": "0.0.1638949",
- "resolved": "https://registry.npmjs.org/devtools-protocol/-/devtools-protocol-0.0.1638949.tgz",
- "integrity": "sha512-mXwg4Fqnv0WR4iuAT/gYUmctNkjILwXFHyZ+m7Ty1dfr0ezZt2U3gnrrJTfRobJTHoXf+IbuFvFITzLrLFjwJA==",
- "dev": true,
- "license": "BSD-3-Clause"
- },
"node_modules/electron-to-chromium": {
"version": "1.5.378",
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.378.tgz",
@@ -1573,13 +2277,6 @@
"dev": true,
"license": "ISC"
},
- "node_modules/emoji-regex": {
- "version": "10.6.0",
- "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
- "integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/esbuild": {
"version": "0.25.12",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz",
@@ -1639,6 +2336,16 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/fd-package-json": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/fd-package-json/-/fd-package-json-2.0.0.tgz",
+ "integrity": "sha512-jKmm9YtsNXN789RS/0mSzOC1NUq9mkVd65vbSSVsKdjGvYXBuE4oWe2QOEoFeRmJg+lPuZxpmrfFclNhoRMneQ==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "walk-up-path": "^4.0.0"
+ }
+ },
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -1657,6 +2364,22 @@
}
}
},
+ "node_modules/formatly": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/formatly/-/formatly-0.3.0.tgz",
+ "integrity": "sha512-9XNj/o4wrRFyhSMJOvsuyMwy8aUfBaZ1VrqHVfohyXf0Sw0e+yfKG+xZaY3arGCOMdwFsqObtzVOc1gU9KiT9w==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "fd-package-json": "^2.0.0"
+ },
+ "bin": {
+ "formatly": "bin/index.mjs"
+ },
+ "engines": {
+ "node": ">=18.3.0"
+ }
+ },
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1682,27 +2405,17 @@
"node": ">=6.9.0"
}
},
- "node_modules/get-caller-file": {
- "version": "2.0.5",
- "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
- "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": "6.* || 8.* || >= 10.*"
- }
- },
- "node_modules/get-east-asian-width": {
- "version": "1.6.0",
- "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
- "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
+ "node_modules/get-tsconfig": {
+ "version": "4.14.0",
+ "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz",
+ "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==",
"dev": true,
"license": "MIT",
- "engines": {
- "node": ">=18"
+ "dependencies": {
+ "resolve-pkg-maps": "^1.0.0"
},
"funding": {
- "url": "https://github.com/sponsors/sindresorhus"
+ "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1"
}
},
"node_modules/https-proxy-agent": {
@@ -1732,6 +2445,16 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
+ "node_modules/jiti": {
+ "version": "2.7.0",
+ "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz",
+ "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jiti": "lib/jiti-cli.mjs"
+ }
+ },
"node_modules/js-levenshtein": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/js-levenshtein/-/js-levenshtein-1.1.6.tgz",
@@ -1772,6 +2495,120 @@
"js-yaml": "bin/js-yaml.js"
}
},
+ "node_modules/jscpd": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/jscpd/-/jscpd-5.0.12.tgz",
+ "integrity": "sha512-87dC+akj2mCywlt8p3xnRlDg0B55u4GDbfE9cuwOOeIxbEuWuIoNqGoYi65A0516aJ4EzAjGwBj1Qb/Ahc8WAQ==",
+ "dev": true,
+ "license": "MIT",
+ "bin": {
+ "jscpd": "run-jscpd.js"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "optionalDependencies": {
+ "jscpd-darwin-arm64": "5.0.12",
+ "jscpd-darwin-x64": "5.0.12",
+ "jscpd-linux-arm64-gnu": "5.0.12",
+ "jscpd-linux-x64-gnu": "5.0.12",
+ "jscpd-linux-x64-musl": "5.0.12",
+ "jscpd-windows-x64-msvc": "5.0.12"
+ }
+ },
+ "node_modules/jscpd-darwin-arm64": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/jscpd-darwin-arm64/-/jscpd-darwin-arm64-5.0.12.tgz",
+ "integrity": "sha512-CQCDYhQY9yOGGeauzkRGYW/QtXurPCjfDkEhUoM/M5UdmpzxfG3i9mnNsaheJ0RdFRf73mL7JaLVRP8U2l5/kA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/jscpd-darwin-x64": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/jscpd-darwin-x64/-/jscpd-darwin-x64-5.0.12.tgz",
+ "integrity": "sha512-7XNCDfChP9fPkIWLepd0zGnTov7/5mR7rJ5Utc1MdWFdgkN//qz6FAJ1FhQA0E/qjFedy9XTucfUCgLybSBsjw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "darwin"
+ ]
+ },
+ "node_modules/jscpd-linux-arm64-gnu": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/jscpd-linux-arm64-gnu/-/jscpd-linux-arm64-gnu-5.0.12.tgz",
+ "integrity": "sha512-i/usHD0/8twzb2Qo4vFWcnuAD4IDLS6K/mTXwYy1CiJZDw4gmjfH45jtjdKUyoutTAiNs+ZWZgx1gIRljUbFuA==",
+ "cpu": [
+ "arm64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/jscpd-linux-x64-gnu": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/jscpd-linux-x64-gnu/-/jscpd-linux-x64-gnu-5.0.12.tgz",
+ "integrity": "sha512-TqaeSTaGawcqyXSrQvYJFtLRn4aQeHgphGGsq2ZtVAg+lPeuMmh81c3bl0yi2dL3gDX1RGBQKBVul1XQknOuoA==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "glibc"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/jscpd-linux-x64-musl": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/jscpd-linux-x64-musl/-/jscpd-linux-x64-musl-5.0.12.tgz",
+ "integrity": "sha512-WelugY1/rdoo0Y0sqJ2XQOIvyIZTfRe+vP3MJe4XvEUwPF0v+9SQoS34FnfblRhsddVixyNkgl7x/sHid9UMJw==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "libc": [
+ "musl"
+ ],
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "linux"
+ ]
+ },
+ "node_modules/jscpd-windows-x64-msvc": {
+ "version": "5.0.12",
+ "resolved": "https://registry.npmjs.org/jscpd-windows-x64-msvc/-/jscpd-windows-x64-msvc-5.0.12.tgz",
+ "integrity": "sha512-01x1klKjvfzI6Of5tI9u72x7TIQZQLLG6kMdsEPJKl/BXbskdknrfCepeTXRBbdFPKs25jfcd0klZ4OdDvww5w==",
+ "cpu": [
+ "x64"
+ ],
+ "dev": true,
+ "license": "MIT",
+ "optional": true,
+ "os": [
+ "win32"
+ ]
+ },
"node_modules/jsesc": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz",
@@ -1805,6 +2642,55 @@
"node": ">=6"
}
},
+ "node_modules/knip": {
+ "version": "6.26.0",
+ "resolved": "https://registry.npmjs.org/knip/-/knip-6.26.0.tgz",
+ "integrity": "sha512-e9eELEEpBpGTd4H4HB7818/DYj9dMzMyUqAddfYwUN/EbSkgIjOuWEF96W/xHsmV0SDrsdXjIM+oZ2xpPzPsBA==",
+ "dev": true,
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/webpro"
+ },
+ {
+ "type": "opencollective",
+ "url": "https://opencollective.com/knip"
+ }
+ ],
+ "license": "ISC",
+ "dependencies": {
+ "fdir": "^6.5.0",
+ "formatly": "^0.3.0",
+ "get-tsconfig": "4.14.0",
+ "jiti": "^2.7.0",
+ "oxc-parser": "^0.137.0",
+ "oxc-resolver": "11.21.3",
+ "picomatch": "^4.0.4",
+ "smol-toml": "^1.6.1",
+ "strip-json-comments": "5.0.3",
+ "tinyglobby": "^0.2.17",
+ "unbash": "^4.0.1",
+ "yaml": "^2.9.0",
+ "zod": "^4.1.11"
+ },
+ "bin": {
+ "knip": "bin/knip.js",
+ "knip-bun": "bin/knip-bun.js"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ }
+ },
+ "node_modules/knip/node_modules/zod": {
+ "version": "4.4.3",
+ "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
+ "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/colinhacks"
+ }
+ },
"node_modules/lru-cache": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz",
@@ -1828,23 +2714,6 @@
"node": ">=10"
}
},
- "node_modules/mitt": {
- "version": "3.0.1",
- "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz",
- "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==",
- "dev": true,
- "license": "MIT"
- },
- "node_modules/modern-tar": {
- "version": "0.7.6",
- "resolved": "https://registry.npmjs.org/modern-tar/-/modern-tar-0.7.6.tgz",
- "integrity": "sha512-sweCIVXzx1aIGTCdzcMlSZt1h8k5Tmk08VNAuRk3IU28XamGiOH5ypi11g6De2CH7PhYqSSnGy2A/EFhbWnVKg==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=18.0.0"
- }
- },
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
@@ -1912,6 +2781,75 @@
"node": ">=12"
}
},
+ "node_modules/oxc-parser": {
+ "version": "0.137.0",
+ "resolved": "https://registry.npmjs.org/oxc-parser/-/oxc-parser-0.137.0.tgz",
+ "integrity": "sha512-yFImD+WLElJpLKy8llG1qe4DCmMsL18peRp8XP1JKfig/gISbJkglnpDtX2aTmAn10kZF7164HbN2H8QPsXxGg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@oxc-project/types": "^0.137.0"
+ },
+ "engines": {
+ "node": "^20.19.0 || >=22.12.0"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-parser/binding-android-arm-eabi": "0.137.0",
+ "@oxc-parser/binding-android-arm64": "0.137.0",
+ "@oxc-parser/binding-darwin-arm64": "0.137.0",
+ "@oxc-parser/binding-darwin-x64": "0.137.0",
+ "@oxc-parser/binding-freebsd-x64": "0.137.0",
+ "@oxc-parser/binding-linux-arm-gnueabihf": "0.137.0",
+ "@oxc-parser/binding-linux-arm-musleabihf": "0.137.0",
+ "@oxc-parser/binding-linux-arm64-gnu": "0.137.0",
+ "@oxc-parser/binding-linux-arm64-musl": "0.137.0",
+ "@oxc-parser/binding-linux-ppc64-gnu": "0.137.0",
+ "@oxc-parser/binding-linux-riscv64-gnu": "0.137.0",
+ "@oxc-parser/binding-linux-riscv64-musl": "0.137.0",
+ "@oxc-parser/binding-linux-s390x-gnu": "0.137.0",
+ "@oxc-parser/binding-linux-x64-gnu": "0.137.0",
+ "@oxc-parser/binding-linux-x64-musl": "0.137.0",
+ "@oxc-parser/binding-openharmony-arm64": "0.137.0",
+ "@oxc-parser/binding-wasm32-wasi": "0.137.0",
+ "@oxc-parser/binding-win32-arm64-msvc": "0.137.0",
+ "@oxc-parser/binding-win32-ia32-msvc": "0.137.0",
+ "@oxc-parser/binding-win32-x64-msvc": "0.137.0"
+ }
+ },
+ "node_modules/oxc-resolver": {
+ "version": "11.21.3",
+ "resolved": "https://registry.npmjs.org/oxc-resolver/-/oxc-resolver-11.21.3.tgz",
+ "integrity": "sha512-2Mx3fKQz7+xgrBONjsxOgCGtMHOn38/HxMzW1I5efwXB5a4lRN0Vp40gYUJFBWJslcrvwoofTrqoTnLbwTd3pA==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/Boshen"
+ },
+ "optionalDependencies": {
+ "@oxc-resolver/binding-android-arm-eabi": "11.21.3",
+ "@oxc-resolver/binding-android-arm64": "11.21.3",
+ "@oxc-resolver/binding-darwin-arm64": "11.21.3",
+ "@oxc-resolver/binding-darwin-x64": "11.21.3",
+ "@oxc-resolver/binding-freebsd-x64": "11.21.3",
+ "@oxc-resolver/binding-linux-arm-gnueabihf": "11.21.3",
+ "@oxc-resolver/binding-linux-arm-musleabihf": "11.21.3",
+ "@oxc-resolver/binding-linux-arm64-gnu": "11.21.3",
+ "@oxc-resolver/binding-linux-arm64-musl": "11.21.3",
+ "@oxc-resolver/binding-linux-ppc64-gnu": "11.21.3",
+ "@oxc-resolver/binding-linux-riscv64-gnu": "11.21.3",
+ "@oxc-resolver/binding-linux-riscv64-musl": "11.21.3",
+ "@oxc-resolver/binding-linux-s390x-gnu": "11.21.3",
+ "@oxc-resolver/binding-linux-x64-gnu": "11.21.3",
+ "@oxc-resolver/binding-linux-x64-musl": "11.21.3",
+ "@oxc-resolver/binding-openharmony-arm64": "11.21.3",
+ "@oxc-resolver/binding-wasm32-wasi": "11.21.3",
+ "@oxc-resolver/binding-win32-arm64-msvc": "11.21.3",
+ "@oxc-resolver/binding-win32-x64-msvc": "11.21.3"
+ }
+ },
"node_modules/parse-json": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/parse-json/-/parse-json-8.3.0.tgz",
@@ -2036,24 +2974,6 @@
"node": "^10 || ^12 || >=14"
}
},
- "node_modules/puppeteer-core": {
- "version": "25.2.1",
- "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-25.2.1.tgz",
- "integrity": "sha512-MwEZ4FFGJ1ZLOmu/04eISxoEMKtCnHyJBRFfgpwPPSYNG6gT6Xw1laNziFSV7uwDcx3jK+ATYIo9SfOd8Uhc3w==",
- "dev": true,
- "license": "Apache-2.0",
- "dependencies": {
- "@puppeteer/browsers": "3.0.5",
- "chromium-bidi": "16.0.1",
- "devtools-protocol": "0.0.1638949",
- "typed-query-selector": "^2.12.2",
- "webdriver-bidi-protocol": "0.4.2",
- "ws": "^8.21.0"
- },
- "engines": {
- "node": ">=22.12.0"
- }
- },
"node_modules/react": {
"version": "19.2.7",
"resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz",
@@ -2127,6 +3047,16 @@
"node": ">=0.10.0"
}
},
+ "node_modules/resolve-pkg-maps": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
+ "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
+ "dev": true,
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
+ }
+ },
"node_modules/rollup": {
"version": "4.62.2",
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
@@ -2188,6 +3118,19 @@
"semver": "bin/semver.js"
}
},
+ "node_modules/smol-toml": {
+ "version": "1.7.0",
+ "resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.0.tgz",
+ "integrity": "sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==",
+ "dev": true,
+ "license": "BSD-3-Clause",
+ "engines": {
+ "node": ">= 18"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/cyyynthia"
+ }
+ },
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -2198,40 +3141,19 @@
"node": ">=0.10.0"
}
},
- "node_modules/string-width": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
- "integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
+ "node_modules/strip-json-comments": {
+ "version": "5.0.3",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.3.tgz",
+ "integrity": "sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==",
"dev": true,
"license": "MIT",
- "dependencies": {
- "emoji-regex": "^10.3.0",
- "get-east-asian-width": "^1.0.0",
- "strip-ansi": "^7.1.0"
- },
"engines": {
- "node": ">=18"
+ "node": ">=14.16"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/strip-ansi": {
- "version": "7.2.0",
- "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
- "integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-regex": "^6.2.2"
- },
- "engines": {
- "node": ">=12"
- },
- "funding": {
- "url": "https://github.com/chalk/strip-ansi?sponsor=1"
- }
- },
"node_modules/supports-color": {
"version": "10.2.2",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-10.2.2.tgz",
@@ -2262,6 +3184,14 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
+ "node_modules/tslib": {
+ "version": "2.8.1",
+ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+ "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
+ "dev": true,
+ "license": "0BSD",
+ "optional": true
+ },
"node_modules/type-fest": {
"version": "4.41.0",
"resolved": "https://registry.npmjs.org/type-fest/-/type-fest-4.41.0.tgz",
@@ -2275,13 +3205,6 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
- "node_modules/typed-query-selector": {
- "version": "2.12.2",
- "resolved": "https://registry.npmjs.org/typed-query-selector/-/typed-query-selector-2.12.2.tgz",
- "integrity": "sha512-EOPFbyIub4ngnEdqi2yOcNeDLaX/0jcE1JoAXQDDMIthap7FoN795lc/SHfIq2d416VufXpM8z/lD+WRm2gfOQ==",
- "dev": true,
- "license": "MIT"
- },
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
@@ -2296,6 +3219,16 @@
"node": ">=14.17"
}
},
+ "node_modules/unbash": {
+ "version": "4.0.3",
+ "resolved": "https://registry.npmjs.org/unbash/-/unbash-4.0.3.tgz",
+ "integrity": "sha512-3cudTErfToSc4Ggv8XGXVNVli/xHKUtUZvaY5UVwhOcUPbQGz7PeaEnT/SAVgNziZtX67KEN9swMUYkLghxA1w==",
+ "dev": true,
+ "license": "ISC",
+ "engines": {
+ "node": ">=14"
+ }
+ },
"node_modules/update-browserslist-db": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
@@ -2409,61 +3342,14 @@
}
}
},
- "node_modules/webdriver-bidi-protocol": {
- "version": "0.4.2",
- "resolved": "https://registry.npmjs.org/webdriver-bidi-protocol/-/webdriver-bidi-protocol-0.4.2.tgz",
- "integrity": "sha512-VSV+fzfChirL3e7jay2yUC7B4HQCGtEWEg/MSSQbK+qWbqeGlRLlXTzPpYr3XGUvbpDHumWZBJxgesg4N7dbtA==",
- "dev": true,
- "license": "Apache-2.0"
- },
- "node_modules/wrap-ansi": {
- "version": "9.0.2",
- "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-9.0.2.tgz",
- "integrity": "sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "ansi-styles": "^6.2.1",
- "string-width": "^7.0.0",
- "strip-ansi": "^7.1.0"
- },
- "engines": {
- "node": ">=18"
- },
- "funding": {
- "url": "https://github.com/chalk/wrap-ansi?sponsor=1"
- }
- },
- "node_modules/ws": {
- "version": "8.21.0",
- "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.0.tgz",
- "integrity": "sha512-Vsp28b7DRcimFQvrqu2Wek3z1iYxDCWqHYB8Qsnk/S4RfaCQzPGPyBNuVjJV3cd6UiKtUtp6sNM77gWvzcCH+g==",
- "dev": true,
- "license": "MIT",
- "engines": {
- "node": ">=10.0.0"
- },
- "peerDependencies": {
- "bufferutil": "^4.0.1",
- "utf-8-validate": ">=5.0.2"
- },
- "peerDependenciesMeta": {
- "bufferutil": {
- "optional": true
- },
- "utf-8-validate": {
- "optional": true
- }
- }
- },
- "node_modules/y18n": {
- "version": "5.0.8",
- "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
- "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
+ "node_modules/walk-up-path": {
+ "version": "4.0.0",
+ "resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
+ "integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
"dev": true,
"license": "ISC",
"engines": {
- "node": ">=10"
+ "node": "20 || >=22"
}
},
"node_modules/yallist": {
@@ -2473,50 +3359,28 @@
"dev": true,
"license": "ISC"
},
+ "node_modules/yaml": {
+ "version": "2.9.0",
+ "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
+ "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "yaml": "bin.mjs"
+ },
+ "engines": {
+ "node": ">= 14.6"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/eemeli"
+ }
+ },
"node_modules/yaml-ast-parser": {
"version": "0.0.43",
"resolved": "https://registry.npmjs.org/yaml-ast-parser/-/yaml-ast-parser-0.0.43.tgz",
"integrity": "sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==",
"dev": true,
"license": "Apache-2.0"
- },
- "node_modules/yargs": {
- "version": "18.0.0",
- "resolved": "https://registry.npmjs.org/yargs/-/yargs-18.0.0.tgz",
- "integrity": "sha512-4UEqdc2RYGHZc7Doyqkrqiln3p9X2DZVxaGbwhn2pi7MrRagKaOcIKe8L3OxYcbhXLgLFUS3zAYuQjKBQgmuNg==",
- "dev": true,
- "license": "MIT",
- "dependencies": {
- "cliui": "^9.0.1",
- "escalade": "^3.1.1",
- "get-caller-file": "^2.0.5",
- "string-width": "^7.2.0",
- "y18n": "^5.0.5",
- "yargs-parser": "^22.0.0"
- },
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=23"
- }
- },
- "node_modules/yargs-parser": {
- "version": "22.0.0",
- "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-22.0.0.tgz",
- "integrity": "sha512-rwu/ClNdSMpkSrUb+d6BRsSkLUq1fmfsY6TOpYzTwvwkg1/NRG85KBy3kq++A8LKQwX6lsu+aWad+2khvuXrqw==",
- "dev": true,
- "license": "ISC",
- "engines": {
- "node": "^20.19.0 || ^22.12.0 || >=23"
- }
- },
- "node_modules/zod": {
- "version": "3.25.76",
- "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz",
- "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==",
- "dev": true,
- "license": "MIT",
- "funding": {
- "url": "https://github.com/sponsors/colinhacks"
- }
}
}
}
diff --git a/apps/web/package.json b/apps/web/package.json
index bb8fa7d..236fab4 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -10,15 +10,22 @@
"generate:api-types": "node scripts/generate-api-types.mjs",
"check:api-types": "node scripts/generate-api-types.mjs --check",
"check:preview-hosts": "node scripts/check-preview-hosts.mjs",
+ "check:design-ssot": "node scripts/check-design-ssot.mjs",
+ "check:dead-code": "knip --include files,dependencies,unlisted,unresolved,binaries --treat-config-hints-as-errors",
+ "check:duplication": "jscpd src ../api/app --min-lines 8 --min-tokens 80 --ignore **/test_*.py,**/*_test.py,**/api.gen.ts,**/*.css,**/*.d.ts --threshold 0.05 --reporters console,threshold --no-tips",
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
"preview": "vite preview",
"typecheck": "tsc -b",
"lint": "tsc -b",
- "e2e": "playwright test",
+ "e2e": "node scripts/run-e2e.mjs",
+ "e2e:list": "node scripts/run-e2e.mjs --list",
+ "e2e:parallel": "playwright test --project=chromium-desktop --project=chromium-mobile --workers=4",
+ "e2e:single-run": "playwright test --project=chromium-single-run --workers=1",
"e2e:headed": "playwright test --headed",
"e2e:ui": "playwright test --ui"
},
"dependencies": {
+ "@tanstack/react-table": "^8.21.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"react-router-dom": "^6.30.1"
@@ -31,8 +38,10 @@
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@vitejs/plugin-react": "^4.6.0",
+ "esbuild": "^0.25.12",
+ "jscpd": "5.0.12",
+ "knip": "^6.26.0",
"openapi-typescript": "^7.13.0",
- "puppeteer-core": "^25.2.1",
"typescript": "^5.8.3",
"vite": "^6.3.5"
}
diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts
index 1c25108..a6f6b04 100644
--- a/apps/web/playwright.config.ts
+++ b/apps/web/playwright.config.ts
@@ -66,6 +66,9 @@ export default defineConfig({
name: "chromium-single-run",
grep: singleRunPattern,
grepInvert: publicAuthPattern,
+ // npm run e2e가 이 프로젝트를 fixture 프로젝트 뒤에 workers=1로 실행한다.
+ // 직접 focused 실행할 때도 npm run e2e:single-run을 사용한다.
+ fullyParallel: false,
use: {
...devices["Desktop Chrome"],
viewport: { width: 1280, height: 800 },
diff --git a/apps/web/public/design-elements/learner-dashboard/background-dark-corner.png b/apps/web/public/design-elements/learner-dashboard/background-dark-corner.png
new file mode 100644
index 0000000..23dd33a
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/background-dark-corner.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/background-dark-sidebar.png b/apps/web/public/design-elements/learner-dashboard/background-dark-sidebar.png
new file mode 100644
index 0000000..3140e90
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/background-dark-sidebar.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/background-light-corner.png b/apps/web/public/design-elements/learner-dashboard/background-light-corner.png
new file mode 100644
index 0000000..10049f4
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/background-light-corner.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/background-light-sidebar.png b/apps/web/public/design-elements/learner-dashboard/background-light-sidebar.png
new file mode 100644
index 0000000..2d0db67
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/background-light-sidebar.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-flowing-ribbon-light.png b/apps/web/public/design-elements/learner-dashboard/card-flowing-ribbon-light.png
new file mode 100644
index 0000000..f711f29
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-flowing-ribbon-light.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-flowing-ribbon.png b/apps/web/public/design-elements/learner-dashboard/card-flowing-ribbon.png
new file mode 100644
index 0000000..5cf7e7c
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-flowing-ribbon.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-folded-ribbon-light.png b/apps/web/public/design-elements/learner-dashboard/card-folded-ribbon-light.png
new file mode 100644
index 0000000..d649eca
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-folded-ribbon-light.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-folded-ribbon.png b/apps/web/public/design-elements/learner-dashboard/card-folded-ribbon.png
new file mode 100644
index 0000000..0f31099
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-folded-ribbon.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-leaf-sprig-light.png b/apps/web/public/design-elements/learner-dashboard/card-leaf-sprig-light.png
new file mode 100644
index 0000000..6621713
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-leaf-sprig-light.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-leaf-sprig.png b/apps/web/public/design-elements/learner-dashboard/card-leaf-sprig.png
new file mode 100644
index 0000000..e8bcef6
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-leaf-sprig.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-orbital-ring-light.png b/apps/web/public/design-elements/learner-dashboard/card-orbital-ring-light.png
new file mode 100644
index 0000000..29df0f4
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-orbital-ring-light.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-orbital-ring.png b/apps/web/public/design-elements/learner-dashboard/card-orbital-ring.png
new file mode 100644
index 0000000..6a67d08
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-orbital-ring.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-paired-leaves-light.png b/apps/web/public/design-elements/learner-dashboard/card-paired-leaves-light.png
new file mode 100644
index 0000000..9673d6c
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-paired-leaves-light.png differ
diff --git a/apps/web/public/design-elements/learner-dashboard/card-paired-leaves.png b/apps/web/public/design-elements/learner-dashboard/card-paired-leaves.png
new file mode 100644
index 0000000..2961ae3
Binary files /dev/null and b/apps/web/public/design-elements/learner-dashboard/card-paired-leaves.png differ
diff --git a/apps/web/scripts/check-design-ssot.mjs b/apps/web/scripts/check-design-ssot.mjs
new file mode 100644
index 0000000..3c6c395
--- /dev/null
+++ b/apps/web/scripts/check-design-ssot.mjs
@@ -0,0 +1,207 @@
+import { promises as fs } from "node:fs";
+import path from "node:path";
+
+const root = process.cwd();
+const failures = [];
+
+async function read(relativePath) {
+ return fs.readFile(path.join(root, relativePath), "utf8");
+}
+
+function forbid(relativePath, source, pattern, message) {
+ const match = source.match(pattern);
+ if (match)
+ failures.push(`${relativePath}: ${message} (${JSON.stringify(match[0])})`);
+}
+
+function countRawColors(source) {
+ return source.match(/#[0-9a-f]{3,8}|rgba?\([^)]*\)/gi)?.length ?? 0;
+}
+
+const shellPath = "src/components/shell/shell.css";
+const surfacePath = "src/components/ui/ui.css";
+const settingsPath = "src/pages/settings/settings.css";
+const sessionPath = "src/pages/Session.tsx";
+const learnerHomePath = "src/pages/LearnerHome.tsx";
+const personaViewModelPath = "src/lib/personaViewModel.ts";
+const appPath = "src/App.tsx";
+const designDocPath = "../../docs/DESIGN_CONCEPT.md";
+const shell = await read(shellPath);
+const surface = await read(surfacePath);
+const settings = await read(settingsPath);
+const session = await read(sessionPath);
+const learnerHome = await read(learnerHomePath);
+const personaViewModel = await read(personaViewModelPath);
+const app = await read(appPath);
+const designDoc = await read(designDocPath);
+
+// 페이지/아트 전용 CSS는 집중 화면의 국소 팔레트를 소유할 수 있다. 다만 전역
+// 토큰으로 승격하지 않은 raw color는 이 기준선 이상 늘어나면 실패시켜 예외가
+// 무제한 확산되는 것을 막는다. 공통 ui/shell은 아래 별도 규칙으로 0개를 강제한다.
+const rawColorBudgets = {
+ "src/pages/session/session.css": 216,
+ "src/pages/session-review/session-review.css": 29,
+ "src/pages/learner-home.css": 25,
+ "src/components/auth/auth-shell.css": 20,
+ "src/pages/login/login.css": 11,
+ "src/components/avatar/client-avatar.css": 7,
+ "src/pages/avatar-expression-lab.css": 7,
+ "src/pages/avatar-preview.css": 5,
+ "src/pages/pending-approval.css": 3,
+};
+
+for (const [relativePath, budget] of Object.entries(rawColorBudgets)) {
+ const count = countRawColors(await read(relativePath));
+ if (count > budget) {
+ failures.push(
+ `${relativePath}: 국소 raw color 예산 초과 (${count}/${budget}). 기존 의미 토큰 또는 페이지 예외 토큰으로 묶어야 합니다`,
+ );
+ }
+}
+
+forbid(
+ shellPath,
+ shell,
+ /\.(?:lh-|sr-|pf-|ad-|ps-|sx-|vg-set__)/,
+ "AppShell SSOT가 페이지 전용 클래스를 스타일하면 안 됩니다",
+);
+forbid(
+ appPath,
+ app,
+ /^import\s+\w+\s+from\s+["']\.\/pages\//m,
+ "역할별 페이지는 초기 번들에 정적 import하면 안 됩니다",
+);
+forbid(
+ appPath,
+ app,
+ /minHeight:\s*["']100vh["']/,
+ "전체 높이 화면은 모바일 viewport 안정성을 위해 100dvh를 사용해야 합니다",
+);
+forbid(
+ shellPath,
+ shell,
+ /!important/,
+ "공통 셸 권위는 !important가 아니라 소유권으로 유지해야 합니다",
+);
+forbid(
+ surfacePath,
+ surface,
+ /!important/,
+ "Surface primitive는 !important에 의존하면 안 됩니다",
+);
+forbid(
+ surfacePath,
+ surface,
+ /#[0-9a-f]{3,8}|rgba?\(/i,
+ "공통 UI 색은 tokens.css 의미 토큰으로만 표현해야 합니다",
+);
+forbid(
+ shellPath,
+ shell,
+ /#[0-9a-f]{3,8}|rgba?\(/i,
+ "공통 셸 색은 tokens.css 의미 토큰으로만 표현해야 합니다",
+);
+forbid(
+ sessionPath,
+ session,
+ /#[0-9a-f]{3,8}|rgba?\(/i,
+ "세션 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다",
+);
+forbid(
+ learnerHomePath,
+ learnerHome,
+ /#[0-9a-f]{3,8}|rgba?\(/i,
+ "학습자 화면의 아바타 팔레트는 personaViewModel SSOT를 우회하면 안 됩니다",
+);
+forbid(
+ settingsPath,
+ settings,
+ /body\[data-page=["']settings["']\][^{]*(?:\.vg-topbar|\.vg-nav|\.vg-main|\.vg-shell)/,
+ "설정 페이지 CSS가 공통 앱 크롬을 재정의하면 안 됩니다",
+);
+
+for (const required of [
+ ".vg-surface.vg-surface--panel",
+ ".vg-surface.vg-surface--inset",
+ ".vg-surface.vg-surface--interactive",
+ "var(--glass-surface)",
+ "var(--glass-border)",
+]) {
+ if (!surface.includes(required)) {
+ failures.push(`${surfacePath}: Surface SSOT 필수 계약 누락 (${required})`);
+ }
+}
+
+for (const required of [
+ "PENDING_PERSONA_AVATAR_APPEARANCE",
+ "personaAvatarAppearance",
+ "personaBaselineExpression",
+]) {
+ if (!personaViewModel.includes(required)) {
+ failures.push(
+ `${personaViewModelPath}: 페르소나 시각 SSOT 필수 계약 누락 (${required})`,
+ );
+ }
+}
+
+for (const required of [
+ "### 0.3 구현 소유권 계약",
+ "DESIGN_VARIANCE 4 / MOTION_INTENSITY 3 / VISUAL_DENSITY 6",
+ "apps/web/src/styles/tokens.css",
+ "apps/web/src/components/ui/",
+ "apps/web/src/components/shell/",
+]) {
+ if (!designDoc.includes(required)) {
+ failures.push(
+ `${designDocPath}: 디자인 방법론 SSOT 필수 계약 누락 (${required})`,
+ );
+ }
+}
+
+for (const page of [
+ "Login",
+ "Onboarding",
+ "PendingApproval",
+ "LearnerHome",
+ "AvatarExpressionLab",
+ "AvatarPreview",
+ "Session",
+ "SessionReview",
+ "Professor",
+ "PersonaStudio",
+ "Admin",
+ "AdminAi",
+ "Settings",
+]) {
+ if (!app.includes(`lazy(() => import("./pages/${page}"))`)) {
+ failures.push(`${appPath}: 라우트 지연 로딩 계약 누락 (${page})`);
+ }
+}
+if (!app.includes(" }>")) {
+ failures.push(
+ `${appPath}: 지연 라우트의 공통 Suspense 로딩 경계가 필요합니다`,
+ );
+}
+
+const legacyThemeHook = path.join(root, "src/pages/settings/useTheme.ts");
+if (
+ await fs
+ .stat(legacyThemeHook)
+ .then(() => true)
+ .catch(() => false)
+) {
+ failures.push(
+ "src/pages/settings/useTheme.ts: 페이지 전용 테마 store를 다시 만들면 안 됩니다",
+ );
+}
+
+if (failures.length > 0) {
+ console.error(
+ "디자인 SSOT 검사 실패\n" + failures.map((item) => `- ${item}`).join("\n"),
+ );
+ process.exit(1);
+}
+
+console.log(
+ "디자인 SSOT 검사 통과: 방법론, AppShell, Theme, Surface, route bundle 소유권이 분리되어 있습니다.",
+);
diff --git a/apps/web/scripts/run-e2e.mjs b/apps/web/scripts/run-e2e.mjs
new file mode 100644
index 0000000..340d118
--- /dev/null
+++ b/apps/web/scripts/run-e2e.mjs
@@ -0,0 +1,56 @@
+import { spawnSync } from "node:child_process";
+import path from "node:path";
+import process from "node:process";
+
+const extraArgs = process.argv.slice(2);
+const conflicting = extraArgs.find(
+ (arg) =>
+ arg === "--project" ||
+ arg.startsWith("--project=") ||
+ arg === "--workers" ||
+ arg.startsWith("--workers="),
+);
+
+if (conflicting) {
+ console.error(
+ `e2e 전체 러너는 프로젝트와 worker를 단계별로 소유합니다 (${conflicting}). ` +
+ "focused 실행은 npm run e2e:parallel 또는 npm run e2e:single-run을 사용하세요.",
+ );
+ process.exit(2);
+}
+
+const playwrightCli = path.resolve(
+ process.cwd(),
+ "node_modules/@playwright/test/cli.js",
+);
+
+function runPhase(label, args) {
+ console.log(`\n[e2e] ${label}`);
+ const result = spawnSync(
+ process.execPath,
+ [playwrightCli, "test", ...args, ...extraArgs],
+ {
+ cwd: process.cwd(),
+ env: process.env,
+ stdio: "inherit",
+ },
+ );
+ if (result.error) {
+ console.error(`[e2e] ${label} 실행 실패: ${result.error.message}`);
+ process.exit(1);
+ }
+ if (result.status !== 0) process.exit(result.status ?? 1);
+}
+
+// Route-fixture/browser-only tests may use the machine's worker pool. Durable DB,
+// provider, and engine tests must start only after that pool drains and then run
+// one at a time against the single local API/gateway runtime.
+runPhase("fixture desktop/mobile 병렬 단계", [
+ "--project=chromium-desktop",
+ "--project=chromium-mobile",
+ `--workers=${process.env.E2E_PARALLEL_WORKERS ?? "4"}`,
+]);
+runPhase("DB/engine single-run 직렬 단계", [
+ "--project=chromium-single-run",
+ "--workers=1",
+]);
diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx
index 5b4e68c..214d9be 100644
--- a/apps/web/src/App.tsx
+++ b/apps/web/src/App.tsx
@@ -15,29 +15,52 @@
보호 라우트는 AuthContext 기반 간단 가드. 과설계 금지.
===================================================================== */
-import { Component, useEffect, type ErrorInfo, type ReactNode } from "react";
-import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
-import { AuthProvider, canAccessRole, initialPathForUser, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
+import {
+ Component,
+ Suspense,
+ lazy,
+ useLayoutEffect,
+ type ErrorInfo,
+ type ReactNode,
+} from "react";
+import {
+ BrowserRouter,
+ Navigate,
+ Route,
+ Routes,
+ useLocation,
+} from "react-router-dom";
+import {
+ AuthProvider,
+ canAccessRole,
+ initialPathForUser,
+ useAuth,
+ roleHomePath,
+ type AuthUser,
+ type Role,
+} from "./lib/auth";
+import { runtimeAssetLabel } from "./lib/runtimeDiagnostics";
-import Login from "./pages/Login";
-import Onboarding from "./pages/Onboarding";
-import PendingApproval from "./pages/PendingApproval";
-import LearnerHome from "./pages/LearnerHome";
-import AvatarExpressionLab from "./pages/AvatarExpressionLab";
-import AvatarPreview from "./pages/AvatarPreview";
-import Session from "./pages/Session";
-import SessionReview from "./pages/SessionReview";
-import Professor from "./pages/Professor";
-import PersonaStudio from "./pages/PersonaStudio";
-import Admin from "./pages/Admin";
-import Settings from "./pages/Settings";
+const Login = lazy(() => import("./pages/Login"));
+const Onboarding = lazy(() => import("./pages/Onboarding"));
+const PendingApproval = lazy(() => import("./pages/PendingApproval"));
+const LearnerHome = lazy(() => import("./pages/LearnerHome"));
+const AvatarExpressionLab = lazy(() => import("./pages/AvatarExpressionLab"));
+const AvatarPreview = lazy(() => import("./pages/AvatarPreview"));
+const Session = lazy(() => import("./pages/Session"));
+const SessionReview = lazy(() => import("./pages/SessionReview"));
+const Professor = lazy(() => import("./pages/Professor"));
+const PersonaStudio = lazy(() => import("./pages/PersonaStudio"));
+const Admin = lazy(() => import("./pages/Admin"));
+const AdminAi = lazy(() => import("./pages/AdminAi"));
+const Settings = lazy(() => import("./pages/Settings"));
/** 부트스트랩 로딩 동안 깜빡임 최소화용 중립 화면. */
function BootScreen() {
return (
;
}
- if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) {
+ if (
+ user &&
+ user.onboardingCompletedAt != null &&
+ (path === "/login" || path === "/onboarding")
+ ) {
return ;
}
return <>{children}>;
@@ -129,27 +156,35 @@ function RootRedirect() {
return ;
}
+function resetDocumentScroll() {
+ window.scrollTo({ top: 0, left: 0, behavior: "auto" });
+ document.documentElement.scrollTop = 0;
+ document.body.scrollTop = 0;
+}
+
function ScrollToTopOnPathChange() {
const { pathname } = useLocation();
- useEffect(() => {
- window.scrollTo({ top: 0, left: 0, behavior: "auto" });
- document.documentElement.scrollTop = 0;
- document.body.scrollTop = 0;
+ useLayoutEffect(() => {
+ const previousRestoration = window.history.scrollRestoration;
+ window.history.scrollRestoration = "manual";
+ const handlePageShow = () => resetDocumentScroll();
+ window.addEventListener("pageshow", handlePageShow);
+ return () => {
+ window.removeEventListener("pageshow", handlePageShow);
+ window.history.scrollRestoration = previousRestoration;
+ };
+ }, []);
+
+ useLayoutEffect(() => {
+ resetDocumentScroll();
+ const frame = window.requestAnimationFrame(resetDocumentScroll);
+ return () => window.cancelAnimationFrame(frame);
}, [pathname]);
return null;
}
-function runtimeAssetLabel(): string {
- if (typeof document === "undefined") return "unknown";
- const script = Array.from(document.scripts)
- .map((item) => item.getAttribute("src") ?? "")
- .find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
- if (!script) return "unknown";
- return script.split("/").pop() ?? script;
-}
-
function RouteErrorFallback({
error,
errorInfo,
@@ -161,7 +196,7 @@ function RouteErrorFallback({
화면을 표시하지 못했습니다
-
- React 라우트 렌더 중 예외가 발생했습니다. 빈 화면 대신 원인 정보를 표시합니다.
+
+ React 라우트 렌더 중 예외가 발생했습니다. 빈 화면 대신 원인 정보를
+ 표시합니다.
{[
- ["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
+ [
+ "path",
+ typeof window === "undefined"
+ ? "unknown"
+ : window.location.pathname,
+ ],
["error", error.message || error.name],
["asset", runtimeAssetLabel()],
- ["componentStack", errorInfo?.componentStack?.trim() || "not captured"],
+ [
+ "componentStack",
+ errorInfo?.componentStack?.trim() || "not captured",
+ ],
].map(([key, value]) => (
;
+ return (
+
+ );
}
return this.props.children;
}
@@ -280,7 +335,9 @@ function AppRoutesWithBoundary() {
const { pathname } = useLocation();
return (
-
+ }>
+
+
);
}
@@ -406,6 +463,14 @@ function AppRoutes() {
}
/>
+
+
+
+ }
+ />
{
+ variant?: AuthShellVariant;
+ children: ReactNode;
+}
+
+/** 로그인·온보딩·승인 대기가 공유하는 viewport 캔버스. */
+export function AuthShell({
+ variant = "botanical",
+ className,
+ children,
+ ...rest
+}: AuthShellProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/auth/auth-shell.css b/apps/web/src/components/auth/auth-shell.css
new file mode 100644
index 0000000..3607761
--- /dev/null
+++ b/apps/web/src/components/auth/auth-shell.css
@@ -0,0 +1,85 @@
+.vg-auth-shell {
+ position: relative;
+ isolation: isolate;
+ inline-size: 100vw;
+ max-inline-size: none;
+ min-block-size: 100vh;
+ min-block-size: 100dvh;
+ overflow-x: clip;
+ color: var(--text-body);
+ background-color: var(--bg-app);
+}
+
+.vg-auth-shell > * {
+ position: relative;
+ z-index: 1;
+}
+
+.vg-auth-shell.vg-auth-shell--botanical {
+ background:
+ var(--asset-botanical-corner) right top / min(64vw, 860px) auto no-repeat,
+ radial-gradient(circle at 72% 10%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 32rem),
+ radial-gradient(circle at 28% 78%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 40rem),
+ var(--glass-canvas);
+}
+
+.vg-auth-shell.vg-auth-shell--botanical::after {
+ content: "";
+ position: absolute;
+ z-index: 0;
+ left: -8vw;
+ bottom: -90px;
+ width: min(66vw, 860px);
+ height: min(74vw, 980px);
+ background: var(--asset-botanical-corner) left bottom / contain no-repeat;
+ opacity: 0.55;
+ transform: rotate(180deg);
+ pointer-events: none;
+}
+
+.vg-auth-shell.vg-auth-shell--room {
+ --auth-room-veil:
+ linear-gradient(
+ 90deg,
+ rgba(247, 244, 236, 0.94) 0%,
+ rgba(243, 238, 227, 0.86) 48%,
+ rgba(238, 231, 217, 0.72) 74%,
+ rgba(232, 223, 207, 0.62) 100%
+ );
+ --auth-room-shade: linear-gradient(180deg, rgba(255, 253, 248, 0.08), rgba(120, 101, 72, 0.12));
+ --auth-hero-text: var(--text-strong);
+ --auth-hero-muted: color-mix(in srgb, var(--text-body) 88%, transparent);
+ --auth-hero-surface: rgba(255, 254, 251, 0.48);
+ --auth-hero-border: rgba(101, 86, 62, 0.22);
+ --auth-hero-chip: rgba(255, 254, 251, 0.62);
+ background:
+ var(--auth-room-veil),
+ var(--asset-login-room) center / cover no-repeat;
+}
+
+[data-theme="dark"] .vg-auth-shell.vg-auth-shell--room {
+ --auth-room-veil:
+ linear-gradient(
+ 90deg,
+ rgba(7, 16, 14, 0.84) 0%,
+ rgba(10, 21, 19, 0.72) 48%,
+ rgba(10, 21, 19, 0.55) 74%,
+ rgba(10, 21, 19, 0.62) 100%
+ );
+ --auth-room-shade: linear-gradient(180deg, rgba(3, 9, 8, 0.18), rgba(3, 9, 8, 0.42));
+ --auth-hero-text: #edf4f2;
+ --auth-hero-muted: rgba(237, 244, 242, 0.72);
+ --auth-hero-surface: rgba(237, 244, 242, 0.08);
+ --auth-hero-border: rgba(255, 255, 255, 0.14);
+ --auth-hero-chip: rgba(255, 255, 255, 0.08);
+}
+
+@media (max-width: 620px) {
+ .vg-auth-shell.vg-auth-shell--botanical::after {
+ left: -30vw;
+ bottom: -24px;
+ width: 110vw;
+ height: 120vw;
+ opacity: 0.42;
+ }
+}
diff --git a/apps/web/src/components/avatar/ClientAvatar.tsx b/apps/web/src/components/avatar/ClientAvatar.tsx
index b7147e3..a2cd189 100644
--- a/apps/web/src/components/avatar/ClientAvatar.tsx
+++ b/apps/web/src/components/avatar/ClientAvatar.tsx
@@ -35,7 +35,6 @@ import { Brows } from "./Brows";
import { Mouth } from "./Mouth";
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
import { useExpressionTransition } from "./useExpressionTransition";
-import { RasterBust } from "./RasterBust";
import "./client-avatar.css";
/* ── 공개 타입 재노출 (기존 import 경로 호환) ──────────────────────────
@@ -232,7 +231,6 @@ export function ClientAvatar({
const outfitColor = persona.outfitColor ?? hairColor;
const irisColor = persona.eyeColor ?? "#2B2B2B";
const realism = Math.min(0.45, Math.max(0.3, persona.realism)); // 안전 클램프
- const useRaster = Boolean(persona.rasterArtSet);
const age = ageLookFor(persona.ageBand);
const expressionLabel = expressionLabelFor(affect);
const live2dModel = useMemo(() => live2dModelForPersonaCode(persona.code), [persona.code]);
@@ -295,7 +293,7 @@ export function ClientAvatar({
data-live2d-expression-count={live2dModel.expressions.length}
data-persona-code={persona.code ?? ""}
data-avatar-animated={motionEnabled ? "true" : "false"}
- data-render-mode={useRaster ? "raster" : "svg"}
+ data-render-mode="svg"
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}, ${expressionLabel}`}
>
@@ -319,28 +317,14 @@ export function ClientAvatar({
reduced={reduced}
/>
- {useRaster ? (
-
- ) : (
-
+
{/* 흉상 그룹: 어깨 호흡(translateY) + 저항 시 미세 회전 */}
{/* 어깨/상반신 실루엣 */}
@@ -381,8 +365,7 @@ export function ClientAvatar({
-
- )}
+
diff --git a/apps/web/src/components/avatar/RasterBust.tsx b/apps/web/src/components/avatar/RasterBust.tsx
index 5518aeb..c3e16b9 100644
--- a/apps/web/src/components/avatar/RasterBust.tsx
+++ b/apps/web/src/components/avatar/RasterBust.tsx
@@ -43,6 +43,18 @@ const PERSONA_GENERATED_ART_SETS = [
];
const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb", "seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
const PSB_CRYING_ART_SETS = new Set(["seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]);
+const EYE_DETAIL_LAYERS: ReadonlyArray<{
+ name: string;
+ mask: string;
+ zIndex: number;
+}> = [
+ { name: "iris-left", mask: "eye-white-left", zIndex: 9 },
+ { name: "iris-right", mask: "eye-white-right", zIndex: 9 },
+ { name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
+ { name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
+ { name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
+ { name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
+];
export function rasterVariantFor(expression: AvatarExpression): RasterVariant {
return VARIANT_FOR_EXPRESSION[expression];
@@ -294,14 +306,6 @@ export function RasterBust({
const browLeftPart = useSadCryingParts ? "brow-sad-left" : "brow-left";
const browRightPart = useSadCryingParts ? "brow-sad-right" : "brow-right";
const tearOpacity = useSadCryingParts ? clamp01(0.82 + openEyes * 0.18 - closedEyes * 0.35) : 0;
- const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
- { name: "iris-left", mask: "eye-white-left", zIndex: 9 },
- { name: "iris-right", mask: "eye-white-right", zIndex: 9 },
- { name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
- { name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
- { name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
- { name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
- ];
const renderLayer = (
name: string,
className: string,
@@ -395,7 +399,7 @@ export function RasterBust({
...eyeApertureClipStyle(name.endsWith("left") ? "left" : "right", upperLidY, lowerLidY, closedEyes),
}),
)}
- {eyeDetailLayers.map(({ name, mask, zIndex }) =>
+ {EYE_DETAIL_LAYERS.map(({ name, mask, zIndex }) =>
renderLayer(name, "vg-raster__layer--eyes", zIndex, {
opacity: openEyes,
transform: layerTransform(
@@ -526,14 +530,6 @@ export function RasterBust({
const hairSway = reduced ? 0 : breath * 0.24 + headTilt * 0.18;
const brow = browPoseForVariant(variant);
const expressionMouth = mouthForVariant(variant);
- const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [
- { name: "iris-left", mask: "eye-white-left", zIndex: 9 },
- { name: "iris-right", mask: "eye-white-right", zIndex: 9 },
- { name: "pupil-left", mask: "eye-white-left", zIndex: 10 },
- { name: "pupil-right", mask: "eye-white-right", zIndex: 10 },
- { name: "highlight-left", mask: "eye-white-left", zIndex: 11 },
- { name: "highlight-right", mask: "eye-white-right", zIndex: 11 },
- ];
return (
))}
- {eyeDetailLayers.map(
+ {EYE_DETAIL_LAYERS.map(
({ name, mask, zIndex }) => (
/.png` 레이어 합성(Live2D식)으로 렌더링한다.
- * 미지정 시 기존 SVG 리그. asset 은 imagegen(gpt-image-2)+BiRefNet 누끼로 생성.
- */
- rasterArtSet?: string;
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
realism: number;
/**
diff --git a/apps/web/src/components/shell/AppShell.tsx b/apps/web/src/components/shell/AppShell.tsx
index e7782ce..5a5db77 100644
--- a/apps/web/src/components/shell/AppShell.tsx
+++ b/apps/web/src/components/shell/AppShell.tsx
@@ -5,6 +5,8 @@ import { designRoleOf, useAuth, type Role } from "../../lib/auth";
export interface AppShellProps {
children: ReactNode;
+ /** 페이지 전용 셸 표면을 위한 추가 클래스 */
+ className?: string;
/** 역할 컨텍스트 라벨 오버라이드 (없으면 user.role 라벨) */
contextLabel?: string;
/** 좌측 네비 숨김 (세션 화면처럼 집중 모드) */
@@ -24,7 +26,7 @@ export interface AppShellProps {
* body[data-role] 은 AuthProvider 가 관리(여기선 셸 골격만).
* 미인증 시에도 안전하게 렌더(네비 없이) — 가드는 라우터(RequireAuth)가 담당.
*/
-export function AppShell({ children, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) {
+export function AppShell({ children, className, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) {
const { user } = useAuth();
const showNav = !hideNav && !!user;
const shellRole = navRole ?? user?.role;
@@ -46,7 +48,16 @@ export function AppShell({ children, contextLabel, hideNav, bleed, wide, hideTop
}, [shellRole, user]);
return (
-
+
{hideTopbar ? null :
}
{showNav ?
: null}
diff --git a/apps/web/src/components/shell/Sidebar.tsx b/apps/web/src/components/shell/Sidebar.tsx
index 1059dda..b6cabf1 100644
--- a/apps/web/src/components/shell/Sidebar.tsx
+++ b/apps/web/src/components/shell/Sidebar.tsx
@@ -29,6 +29,7 @@ const NAV_BY_ROLE: Record
= {
],
admin: [
{ to: "/admin", label: "운영 홈", icon: "shield", end: true },
+ { to: "/admin/ai", label: "AI 운영", icon: "session" },
{ to: "/admin/users", label: "사용자", icon: "users" },
{ to: "/admin/access", label: "권한", icon: "settings" },
{ to: "/admin/tickets", label: "티켓", icon: "review" },
diff --git a/apps/web/src/components/shell/Topbar.tsx b/apps/web/src/components/shell/Topbar.tsx
index 20c625d..b15f0cb 100644
--- a/apps/web/src/components/shell/Topbar.tsx
+++ b/apps/web/src/components/shell/Topbar.tsx
@@ -1,8 +1,7 @@
-import { useEffect, useState } from "react";
import { Link, useLocation, useNavigate } from "react-router-dom";
import { Icon } from "../ui/Icon";
import { accessibleRolesFor, canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth";
-import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme";
+import { useTheme } from "../../lib/useTheme";
/** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */
function BrandMark() {
@@ -20,15 +19,6 @@ function BrandMark() {
);
}
-/** 라이트/다크 토글 (data-theme 반영). 토큰만으로 전환. */
-function useTheme(): [boolean, () => void] {
- const [theme, setTheme] = useState(() => readInitialTheme());
- useEffect(() => {
- applyTheme(theme);
- }, [theme]);
- return [theme === "dark", () => setTheme((current) => (current === "dark" ? "light" : "dark"))];
-}
-
function initials(name?: string | null): string {
const trimmed = (name ?? "").trim();
if (!trimmed) return "·";
@@ -46,7 +36,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const location = useLocation();
- const [dark, toggleTheme] = useTheme();
+ const [dark, setDark] = useTheme();
const label = contextLabel ?? (user ? roleLabel(user.role) : null);
const switchRoles: Role[] = user ? accessibleRolesFor(user) : [];
@@ -100,7 +90,7 @@ export function Topbar({ contextLabel }: TopbarProps) {
setDark(!dark)}
aria-label={dark ? "라이트 모드로" : "다크 모드로"}
title={dark ? "라이트 모드" : "다크 모드"}
>
diff --git a/apps/web/src/components/shell/shell.css b/apps/web/src/components/shell/shell.css
index fb9ed3a..5b88778 100644
--- a/apps/web/src/components/shell/shell.css
+++ b/apps/web/src/components/shell/shell.css
@@ -5,15 +5,22 @@
===================================================================== */
.vg-shell {
- min-height: 100vh;
+ width: 100%;
+ height: 100vh;
+ height: 100dvh;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
background: var(--bg-app);
}
.vg-shell--fullscreen {
- min-height: 100dvh;
+ height: 100dvh;
}
/* ── 톱바 ── */
.vg-topbar {
+ flex: 0 0 var(--topbar-h);
min-width: 0;
height: var(--topbar-h);
background: var(--bg-surface);
@@ -104,50 +111,6 @@
text-decoration: none;
}
-/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */
-body[data-role="admin"] .vg-topbar {
- background: #17211f;
- border-bottom-color: rgba(255, 255, 255, 0.08);
- color: #eef4f2;
-}
-body[data-role="admin"] .vg-topbar__wm,
-body[data-role="admin"] .vg-topbar__role,
-body[data-role="admin"] .vg-topbar__uname {
- color: #eef4f2;
-}
-body[data-role="admin"] .vg-topbar__brand svg {
- color: #7eb8ad;
-}
-body[data-role="admin"] .vg-topbar__user {
- background: rgba(255, 255, 255, 0.08);
-}
-body[data-role="admin"] .vg-topbar__switch {
- border-color: rgba(255, 255, 255, 0.1);
- background: rgba(255, 255, 255, 0.06);
-}
-body[data-role="admin"] .vg-topbar__switch-link {
- color: rgba(238, 244, 242, 0.72);
-}
-body[data-role="admin"] .vg-topbar__switch-link:hover,
-body[data-role="admin"] .vg-topbar__switch-link.is-active {
- background: rgba(255, 255, 255, 0.1);
- color: #ffffff;
-}
-body[data-role="admin"] .vg-topbar__avatar {
- background: rgba(126, 184, 173, 0.18);
- color: #bfe0d9;
-}
-body[data-role="admin"] .vg-topbar__role {
- border-left-color: rgba(255, 255, 255, 0.12);
-}
-body[data-role="admin"] .vg-iconbtn {
- color: rgba(238, 244, 242, 0.78);
-}
-body[data-role="admin"] .vg-iconbtn:hover {
- background: rgba(255, 255, 255, 0.09);
- color: #ffffff;
-}
-
/* 톱바 아이콘 버튼 (테마/로그아웃) */
.vg-iconbtn {
display: inline-flex;
@@ -216,6 +179,9 @@ body[data-role="admin"] .vg-iconbtn:hover {
--nav-cur: var(--nav-w);
display: grid;
grid-template-columns: var(--nav-cur) 1fr;
+ flex: 1 1 auto;
+ min-height: 0;
+ overflow: hidden;
align-items: start;
/* 전체 높이 사이드바 구분선: sticky 네비(뷰포트 높이)에 의존하지 않고
본문 그리드 전체 높이를 따라가는 컨테이너 배경 하어라인. */
@@ -231,10 +197,12 @@ body[data-role="admin"] .vg-iconbtn:hover {
/* ── 좌측 네비 ── */
.vg-nav {
- position: sticky;
- top: var(--topbar-h);
+ position: relative;
+ top: auto;
align-self: start;
- height: calc(100vh - var(--topbar-h));
+ width: 100%;
+ height: 100%;
+ min-height: 0;
background: var(--bg-surface);
/* 구분선은 .vg-shell__body 컨테이너 배경(전체 높이)이 그린다. */
padding: var(--sp-5) var(--sp-3);
@@ -299,86 +267,15 @@ body[data-role="admin"] .vg-iconbtn:hover {
color: var(--text-muted);
}
-body[data-role="admin"] .vg-shell__body {
- background-image: linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08));
-}
-body[data-role="admin"] .vg-nav {
- background:
- linear-gradient(180deg, rgba(23, 33, 31, 0.98), rgba(26, 38, 42, 0.98)),
- var(--asset-warm-elements) center / cover no-repeat;
- color: #eef4f2;
-}
-body[data-role="admin"] .vg-nav__label,
-body[data-role="admin"] .vg-nav__ethic {
- color: rgba(238, 244, 242, 0.54);
-}
-body[data-role="admin"] .vg-nav__item {
- color: rgba(238, 244, 242, 0.76);
-}
-body[data-role="admin"] .vg-nav__item:hover {
- background: rgba(255, 255, 255, 0.08);
- color: #ffffff;
-}
-body[data-role="admin"] .vg-nav__item .vg-nav__ic {
- color: rgba(191, 224, 217, 0.72);
-}
-body[data-role="admin"] .vg-nav__item.is-active {
- background: rgba(126, 184, 173, 0.22);
- color: #ffffff;
-}
-body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic {
- color: #91c8bd;
-}
-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 방지 */
+ height: 100%;
+ min-height: 0;
+ overflow-x: hidden;
+ overflow-y: auto;
+ overscroll-behavior-y: contain;
+ scrollbar-gutter: stable;
padding: var(--sp-7) var(--sp-6) var(--sp-8);
}
.vg-main__inner {
@@ -402,9 +299,6 @@ body[data-role="instructor"] .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;
}
@@ -427,15 +321,17 @@ body[data-role="instructor"] .vg-nav__foot {
scroll-padding-bottom: var(--sp-6);
}
.vg-shell__body {
- display: block;
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: 58px minmax(0, 1fr);
background-image: none; /* 네비가 상단 가로바로 전환 — 세로 구분선 제거 */
}
.vg-shell__body--bare {
display: block;
}
.vg-nav {
- position: sticky;
- top: var(--topbar-h);
+ position: relative;
+ top: auto;
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
@@ -490,186 +386,196 @@ body[data-role="instructor"] .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;
}
}
-/* ── Unified role chrome
- 역할별 제품처럼 보이던 톱바/사이드바 변형을 하나의 앱 크롬으로 통합한다.
- 역할 차이는 tokens.css의 accent만 사용하고, 형태·밀도·배경 체계는 동일하게 유지한다. */
-body[data-role="admin"] .vg-topbar,
-body[data-role="instructor"] .vg-topbar {
- background: var(--bg-surface);
- border-bottom-color: var(--hair);
+/* ── Botanical glass canvas ── */
+body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+}
+body[data-role] .vg-shell--botanical .vg-nav,
+body[data-role] .vg-shell--botanical .vg-main {
+ position: relative;
+ z-index: 1;
+}
+.vg-shell--botanical .vg-main:not(.vg-main--bleed),
+.vg-shell--learner-dashboard .vg-main:not(.vg-main--bleed) {
+ background: transparent;
+}
+/* 학습 대시보드 전용 규칙은 콘텐츠 패딩만 소유한다. 앱 크롬은 아래 SSOT를 그대로 쓴다. */
+.vg-shell--learner-dashboard .vg-main:not(.vg-main--bleed) {
+ padding: 36px 32px 72px;
+ background: transparent;
+}
+/* ── App chrome authority (파일 최종 우선순위) ── */
+.vg-shell--botanical {
+ --topbar-h: 64px;
+ --nav-w: 230px;
+}
+body[data-role] .vg-shell--botanical .vg-topbar {
+ height: var(--topbar-h);
+ background: color-mix(in srgb, var(--bg-surface) 76%, transparent);
+ border-bottom-color: var(--botanical-hair);
+ color: var(--text-strong);
+ -webkit-backdrop-filter: var(--glass-blur);
+ backdrop-filter: var(--glass-blur);
+}
+[data-theme="dark"] body[data-role] .vg-shell--botanical .vg-topbar {
+ background: color-mix(in srgb, var(--bg-app) 76%, transparent);
+}
+body[data-role] .vg-shell--botanical .vg-topbar__wm,
+body[data-role] .vg-shell--botanical .vg-topbar__role,
+body[data-role] .vg-shell--botanical .vg-topbar__uname,
+body[data-role] .vg-shell--botanical .vg-iconbtn {
color: var(--text-strong);
}
-body[data-role="admin"] .vg-topbar__wm,
-body[data-role="admin"] .vg-topbar__role,
-body[data-role="admin"] .vg-topbar__uname,
-body[data-role="admin"] .vg-iconbtn,
-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: var(--text-strong);
+body[data-role] .vg-shell--botanical .vg-topbar__wm {
+ font-size: 19px;
+ font-weight: 760;
}
-body[data-role="admin"] .vg-topbar__wm .v,
-body[data-role="instructor"] .vg-topbar__wm .v,
-body[data-role="admin"] .vg-topbar__mark,
-body[data-role="instructor"] .vg-topbar__mark,
-body[data-role="admin"] .vg-topbar__brand svg,
-body[data-role="instructor"] .vg-topbar__brand svg {
- color: var(--accent);
+body[data-role] .vg-shell--botanical .vg-topbar__role {
+ border-left-color: var(--botanical-hair);
+ font-size: 14px;
+ letter-spacing: 0;
}
-body[data-role="admin"] .vg-topbar__role,
-body[data-role="instructor"] .vg-topbar__role {
- border-left-color: var(--hair);
-}
-body[data-role="admin"] .vg-topbar__user,
-body[data-role="instructor"] .vg-topbar__user {
+body[data-role] .vg-shell--botanical .vg-topbar__user {
background: var(--bg-surface-2);
}
-body[data-role="admin"] .vg-topbar__avatar,
-body[data-role="instructor"] .vg-topbar__avatar {
+body[data-role] .vg-shell--botanical .vg-topbar__avatar {
background: var(--accent-tint);
color: var(--accent-deep);
}
-body[data-role="admin"] .vg-iconbtn:hover,
-body[data-role="instructor"] .vg-iconbtn:hover {
- background: var(--bg-surface-2);
- color: var(--text-strong);
-}
-body[data-role="admin"] .vg-shell__body,
-body[data-role="instructor"] .vg-shell__body {
+body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
--nav-cur: var(--nav-w);
- background-image: linear-gradient(var(--hair), var(--hair));
+ background-color: var(--glass-canvas);
+ background-image:
+ linear-gradient(var(--botanical-hair), var(--botanical-hair)),
+ radial-gradient(circle at 68% 18%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 34rem),
+ radial-gradient(circle at 42% 72%, color-mix(in srgb, var(--botanical-gold) 13%, transparent), transparent 42rem),
+ linear-gradient(135deg, color-mix(in srgb, var(--bg-app) 28%, transparent), color-mix(in srgb, var(--bg-app) 62%, transparent)),
+ var(--asset-botanical-corner),
+ radial-gradient(circle at 76% 10%, var(--botanical-glow), transparent 30rem);
+ background-repeat: no-repeat;
+ background-position: var(--nav-cur) 0, center top, center bottom, center top, right top, center top;
+ background-size: 1px 100%, 100% 52rem, 100% 62rem, 100% 46rem, min(66vw, 980px) auto, 100% 46rem;
}
-body[data-role="admin"] .vg-nav,
-body[data-role="instructor"] .vg-nav {
- background: var(--bg-surface);
- color: var(--text-strong);
+body[data-role] .vg-shell--botanical .vg-nav {
+ top: auto;
padding: var(--sp-5) var(--sp-3);
+ background:
+ linear-gradient(180deg, color-mix(in srgb, var(--bg-surface) 34%, transparent), color-mix(in srgb, var(--bg-app) 54%, transparent)),
+ var(--asset-botanical-sidebar) center bottom / auto 72% no-repeat,
+ color-mix(in srgb, var(--bg-surface) 62%, transparent);
+ color: var(--text-strong);
+ -webkit-backdrop-filter: var(--glass-blur);
+ backdrop-filter: var(--glass-blur);
}
-body[data-role="admin"] .vg-nav__label,
-body[data-role="admin"] .vg-nav__ethic,
-body[data-role="instructor"] .vg-nav__label,
-body[data-role="instructor"] .vg-nav__ethic {
+body[data-role] .vg-shell--botanical .vg-nav__label,
+body[data-role] .vg-shell--botanical .vg-nav__ethic {
color: var(--text-muted);
}
-body[data-role="admin"] .vg-nav__item,
-body[data-role="instructor"] .vg-nav__item {
+body[data-role] .vg-shell--botanical .vg-nav__item {
+ position: relative;
+ isolation: isolate;
+ overflow: hidden;
+ min-height: 50px;
+ padding: 10px 12px;
flex-direction: row;
justify-content: flex-start;
gap: 11px;
- min-height: 0;
- padding: 9px 12px;
+ border: 1px solid transparent;
color: var(--text-body);
- font-size: 14px;
+ font-size: 15px;
text-align: left;
}
-body[data-role="admin"] .vg-nav__item:hover,
-body[data-role="instructor"] .vg-nav__item:hover {
- background: var(--bg-surface-2);
+body[data-role] .vg-shell--botanical .vg-nav__item:hover {
+ border-color: color-mix(in srgb, var(--botanical-gold) 18%, var(--border-subtle));
+ background: color-mix(in srgb, var(--bg-surface) 86%, var(--bg-tint));
color: var(--text-strong);
}
-body[data-role="admin"] .vg-nav__item .vg-nav__ic,
-body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
- color: var(--text-muted);
+body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
+ padding-right: 58px;
+ border-color: color-mix(in srgb, var(--botanical-gold) 34%, var(--border-subtle));
+ background: linear-gradient(
+ 112deg,
+ color-mix(in srgb, var(--bg-surface) 94%, var(--botanical-gold)),
+ color-mix(in srgb, var(--bg-surface-2) 78%, transparent)
+ );
+ color: var(--text-strong);
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 84%, transparent);
}
-body[data-role="admin"] .vg-nav__item.is-active,
-body[data-role="instructor"] .vg-nav__item.is-active {
- background: var(--accent-tint);
- color: var(--accent-deep);
-}
-body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic,
-body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
+body[data-role] .vg-shell--botanical .vg-nav__item.is-active .vg-nav__ic {
color: var(--accent);
}
-body[data-role="admin"] .vg-nav__foot,
-body[data-role="instructor"] .vg-nav__foot {
- border-top-color: var(--hair);
+body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
+ content: "";
+ position: absolute;
+ z-index: -1;
+ right: 0;
+ bottom: 0;
+ width: 58px;
+ height: 100%;
+ background: var(--asset-botanical-leaf) right -20px bottom -42px / 76px 96px no-repeat;
+ opacity: 0.28;
+ pointer-events: none;
+ display: block;
+}
+body[data-role] .vg-shell--botanical .vg-nav__foot {
+ border-top-color: var(--botanical-hair);
}
@media (max-width: 1024px) {
- body[data-role="admin"] .vg-shell__body,
- body[data-role="instructor"] .vg-shell__body {
+ body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
--nav-cur: var(--nav-w-collapsed);
}
- body[data-role="admin"] .vg-nav,
- body[data-role="instructor"] .vg-nav {
+ body[data-role] .vg-shell--botanical .vg-nav {
padding: var(--sp-4) var(--sp-2);
+ background-image: none;
}
- body[data-role="admin"] .vg-nav__item,
- body[data-role="instructor"] .vg-nav__item {
+ body[data-role] .vg-shell--botanical .vg-nav__item,
+ body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
+ min-height: 46px;
justify-content: center;
padding: 11px;
}
+ body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
+ display: none;
+ }
}
@media (max-width: 720px) {
- body[data-role="admin"] .vg-shell__body,
- body[data-role="instructor"] .vg-shell__body {
- display: block;
- background-image: none;
+ body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr);
+ grid-template-rows: 58px minmax(0, 1fr);
+ background-image:
+ radial-gradient(circle at 74% 12%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 22rem),
+ radial-gradient(circle at 18% 82%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 28rem),
+ var(--asset-botanical-corner);
+ background-repeat: no-repeat;
+ background-position: right top, left bottom, right -140px bottom -80px;
+ background-size: 100% 34rem, 100% 38rem, 430px auto;
}
- body[data-role="admin"] .vg-nav,
- body[data-role="instructor"] .vg-nav {
- position: sticky;
- top: var(--topbar-h);
+ body[data-role] .vg-shell--botanical .vg-nav {
+ position: relative;
+ top: auto;
z-index: 29;
height: 58px;
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
- border-bottom: 1px solid var(--hair);
- flex-direction: row;
- align-items: center;
- justify-content: flex-start;
- gap: 8px;
- overflow-x: auto;
- overflow-y: hidden;
+ border-bottom: 1px solid var(--botanical-hair);
+ background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
+ -webkit-backdrop-filter: var(--glass-blur);
+ backdrop-filter: var(--glass-blur);
}
- body[data-role="admin"] .vg-nav__item,
- body[data-role="instructor"] .vg-nav__item {
+ body[data-role] .vg-shell--botanical .vg-nav__item,
+ body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
min-width: 112px;
height: 44px;
- justify-content: center;
padding: 0 14px;
gap: 8px;
- white-space: nowrap;
- }
- body[data-role="admin"] .vg-nav__item span:not(.vg-nav__ic),
- body[data-role="instructor"] .vg-nav__item span:not(.vg-nav__ic) {
- display: inline;
- font-size: 13px;
}
}
diff --git a/apps/web/src/components/ui/Card.tsx b/apps/web/src/components/ui/Card.tsx
index 1d32a7d..2ce914d 100644
--- a/apps/web/src/components/ui/Card.tsx
+++ b/apps/web/src/components/ui/Card.tsx
@@ -1,4 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
+import { Surface } from "./Surface";
export interface CardProps extends HTMLAttributes {
/** 그림자 제거(헤어라인만) */
@@ -10,17 +11,9 @@ export interface CardProps extends HTMLAttributes {
/** 카드: 헤어라인 + 미세 그림자. 상단 강조선 금지. radius 8px. §7.3 */
export function Card({ flat, tint, className, children, ...rest }: CardProps) {
- const cls = [
- "vg-card",
- flat ? "vg-card--flat" : "",
- tint ? "vg-card--tint" : "",
- className ?? "",
- ]
- .filter(Boolean)
- .join(" ");
return (
-
+
{children}
-
+
);
}
diff --git a/apps/web/src/components/ui/EmptyState.tsx b/apps/web/src/components/ui/EmptyState.tsx
new file mode 100644
index 0000000..26831e5
--- /dev/null
+++ b/apps/web/src/components/ui/EmptyState.tsx
@@ -0,0 +1,26 @@
+import type { HTMLAttributes, ReactNode } from "react";
+
+export interface EmptyStateProps extends Omit<
+ HTMLAttributes,
+ "title"
+> {
+ title: ReactNode;
+ description: ReactNode;
+ descriptionAs?: "span" | "p" | "div";
+}
+
+/** 데이터 없음·준비 중 상태의 공통 의미 구조. 배치와 표면은 호출 화면이 소유한다. */
+export function EmptyState({
+ title,
+ description,
+ descriptionAs: Description = "span",
+ className = "vg-empty",
+ ...rest
+}: EmptyStateProps) {
+ return (
+
+ {title}
+ {description}
+
+ );
+}
diff --git a/apps/web/src/components/ui/Panel.tsx b/apps/web/src/components/ui/Panel.tsx
index 56b594f..7945853 100644
--- a/apps/web/src/components/ui/Panel.tsx
+++ b/apps/web/src/components/ui/Panel.tsx
@@ -1,4 +1,5 @@
import type { HTMLAttributes, ReactNode } from "react";
+import { Surface } from "./Surface";
export interface PanelProps extends HTMLAttributes {
/** 그림자 제거 */
@@ -10,17 +11,9 @@ export interface PanelProps extends HTMLAttributes {
/** 패널: 카드보다 큰 radius(12px) + 넉넉한 패딩(32px). §7.3 */
export function Panel({ flat, tint, className, children, ...rest }: PanelProps) {
- const cls = [
- "vg-panel",
- flat ? "vg-panel--flat" : "",
- tint ? "vg-panel--tint" : "",
- className ?? "",
- ]
- .filter(Boolean)
- .join(" ");
return (
-
+
{children}
-
+
);
}
diff --git a/apps/web/src/components/ui/SectionHead.tsx b/apps/web/src/components/ui/SectionHead.tsx
deleted file mode 100644
index 76db20e..0000000
--- a/apps/web/src/components/ui/SectionHead.tsx
+++ /dev/null
@@ -1,51 +0,0 @@
-import type { ReactNode } from "react";
-import { Kicker } from "./Kicker";
-
-export interface SectionHeadProps {
- /** kicker 텍스트 (상단 소제목) */
- kicker?: ReactNode;
- /** kicker dot 표시 */
- dot?: boolean;
- /** 섹션 제목 (h2) */
- title?: ReactNode;
- /** 보조 설명 */
- desc?: ReactNode;
- /** 제목 우측 액션 영역 */
- action?: ReactNode;
- className?: string;
-}
-
-/** SectionHead — kicker + 제목 + 설명. 한 화면 한 메시지의 위계 헤더. */
-export function SectionHead({
- kicker,
- dot = true,
- title,
- desc,
- action,
- className,
-}: SectionHeadProps) {
- const cls = ["vg-sechead", className ?? ""].filter(Boolean).join(" ");
- return (
-
- {kicker ? (
-
- {kicker}
-
- ) : null}
- {(title || action) && (
-
- {title ?
{title} : }
- {action}
-
- )}
- {desc ?
{desc}
: null}
-
- );
-}
diff --git a/apps/web/src/components/ui/StatLine.tsx b/apps/web/src/components/ui/StatLine.tsx
deleted file mode 100644
index e057588..0000000
--- a/apps/web/src/components/ui/StatLine.tsx
+++ /dev/null
@@ -1,36 +0,0 @@
-import { Fragment } from "react";
-import type { ReactNode } from "react";
-
-export interface Stat {
- /** 숫자/값 (tabular 표기) */
- num: ReactNode;
- /** 라벨 */
- label: ReactNode;
-}
-
-export interface StatLineProps {
- stats: Stat[];
- /** 항목 사이 세로 구분선 */
- separators?: boolean;
- className?: string;
-}
-
-/** StatLine — 요약 스탯 가로 나열. 숫자는 tabular-nums. */
-export function StatLine({ stats, separators = true, className }: StatLineProps) {
- const cls = ["vg-statline", className ?? ""].filter(Boolean).join(" ");
- return (
-
- {stats.map((s, i) => (
-
- {separators && i > 0 ? (
-
- ) : null}
-
- {s.num}
- {s.label}
-
-
- ))}
-
- );
-}
diff --git a/apps/web/src/components/ui/Surface.tsx b/apps/web/src/components/ui/Surface.tsx
new file mode 100644
index 0000000..12a94dc
--- /dev/null
+++ b/apps/web/src/components/ui/Surface.tsx
@@ -0,0 +1,52 @@
+import type { HTMLAttributes, ReactNode } from "react";
+
+export type SurfaceVariant = "panel" | "inset" | "interactive";
+
+export interface SurfaceOptions {
+ variant?: SurfaceVariant;
+ flat?: boolean;
+ tint?: boolean;
+}
+
+/** raw element(button/li/section 포함)도 같은 표면 계약을 쓰게 하는 class builder. */
+export function surfaceClassName(
+ className?: string,
+ { variant = "panel", flat, tint }: SurfaceOptions = {},
+) {
+ return [
+ "vg-surface",
+ `vg-surface--${variant}`,
+ flat ? "vg-surface--flat" : "",
+ tint ? "vg-surface--tint" : "",
+ className ?? "",
+ ]
+ .filter(Boolean)
+ .join(" ");
+}
+
+export interface SurfaceProps extends HTMLAttributes, SurfaceOptions {
+ children?: ReactNode;
+}
+
+/**
+ * Surface — 배경·헤어라인·그림자·backdrop-filter의 단일 소유자.
+ * 페이지 CSS는 이 컴포넌트의 배치와 내부 타이포만 소유한다.
+ */
+export function Surface({
+ variant = "panel",
+ flat,
+ tint,
+ className,
+ children,
+ ...rest
+}: SurfaceProps) {
+ return (
+
+ {children}
+
+ );
+}
diff --git a/apps/web/src/components/ui/index.ts b/apps/web/src/components/ui/index.ts
index 263c0d4..a0b5ea5 100644
--- a/apps/web/src/components/ui/index.ts
+++ b/apps/web/src/components/ui/index.ts
@@ -13,11 +13,14 @@ export type { CardProps } from "./Card";
export { Panel } from "./Panel";
export type { PanelProps } from "./Panel";
+export { Surface, surfaceClassName } from "./Surface";
+export type { SurfaceProps, SurfaceOptions, SurfaceVariant } from "./Surface";
+
export { Kicker } from "./Kicker";
export type { KickerProps } from "./Kicker";
-export { SectionHead } from "./SectionHead";
-export type { SectionHeadProps } from "./SectionHead";
+export { EmptyState } from "./EmptyState";
+export type { EmptyStateProps } from "./EmptyState";
export { Badge } from "./Badge";
export type { BadgeProps, BadgeTone } from "./Badge";
@@ -25,9 +28,6 @@ export type { BadgeProps, BadgeTone } from "./Badge";
export { Dot } from "./Dot";
export type { DotProps, DotTone } from "./Dot";
-export { StatLine } from "./StatLine";
-export type { StatLineProps, Stat } from "./StatLine";
-
export { ProgressBar } from "./ProgressBar";
export type { ProgressBarProps, ProgressTone } from "./ProgressBar";
diff --git a/apps/web/src/components/ui/ui.css b/apps/web/src/components/ui/ui.css
index 5a3bfdc..c710d3f 100644
--- a/apps/web/src/components/ui/ui.css
+++ b/apps/web/src/components/ui/ui.css
@@ -83,16 +83,18 @@
}
.vg-btn--primary {
- background: var(--accent);
+ background: linear-gradient(105deg, color-mix(in srgb, var(--accent) 90%, var(--bg-surface)), var(--accent));
color: var(--text-on-accent);
+ border-color: color-mix(in srgb, var(--botanical-gold) 24%, var(--accent));
+ box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 32%, transparent);
}
.vg-btn--primary:hover:not(:disabled) {
background: var(--accent-deep);
}
.vg-btn--secondary {
- background: var(--bg-surface);
+ background: var(--surface-gradient);
color: var(--accent);
- border-color: var(--accent);
+ border-color: color-mix(in srgb, var(--accent) 62%, var(--botanical-hair));
}
.vg-btn--secondary:hover:not(:disabled) {
background: var(--accent-tint);
@@ -107,42 +109,61 @@
}
.vg-btn--danger {
background: var(--crit-solid);
- color: #fff;
+ color: var(--text-on-accent);
}
.vg-btn--danger:hover:not(:disabled) {
background: var(--crit-text);
}
-/* ── Card / Panel §7.3 (헤어라인 + 미세 그림자, 상단 강조선 금지) ── */
-.vg-card {
- background: var(--bg-surface);
- border: 1px solid var(--border-subtle);
+/* ── Surface SSOT ── */
+.vg-surface.vg-surface--panel {
+ background: var(--glass-specular), var(--glass-surface);
+ border: 1px solid var(--glass-border);
border-radius: var(--radius);
- padding: var(--sp-5);
- box-shadow: var(--shadow-sm);
+ box-shadow: var(--glass-edge-shadow), var(--glass-shadow);
+ -webkit-backdrop-filter: var(--glass-blur);
+ backdrop-filter: var(--glass-blur);
}
-.vg-card--flat {
+.vg-surface.vg-surface--inset {
+ background: var(--glass-specular-inset), var(--glass-surface-inset);
+ border: 0;
+ border-radius: 0;
+ box-shadow: var(--glass-inset-shadow);
+ -webkit-backdrop-filter: blur(12px) saturate(110%);
+ backdrop-filter: blur(12px) saturate(110%);
+}
+.vg-surface.vg-surface--interactive {
+ background: var(--glass-specular-inset), var(--glass-surface-inset);
+ border: 1px solid var(--glass-inset-border);
+ border-radius: var(--radius);
+ box-shadow: var(--glass-inset-shadow);
+ -webkit-backdrop-filter: blur(12px) saturate(110%);
+ backdrop-filter: blur(12px) saturate(110%);
+ transition:
+ background var(--dur-base) var(--ease-out),
+ border-color var(--dur-base) var(--ease-out),
+ transform var(--dur-fast) var(--ease-out);
+}
+.vg-surface.vg-surface--interactive:hover {
+ border-color: color-mix(in srgb, var(--accent) 48%, var(--glass-border));
+ background: var(--glass-specular), var(--glass-surface-strong);
+}
+.vg-surface.vg-surface--flat {
box-shadow: none;
}
-.vg-card--tint {
+.vg-surface.vg-surface--tint {
background: var(--bg-tint);
border-color: transparent;
box-shadow: none;
}
+
+/* ── Card / Panel 크기 계약. 표면은 위 Surface만 소유한다. ── */
+.vg-card {
+ padding: var(--sp-5);
+}
.vg-panel {
- background: var(--bg-surface);
- border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: var(--sp-6);
- box-shadow: var(--shadow-sm);
-}
-.vg-panel--flat {
- box-shadow: none;
-}
-.vg-panel--tint {
- background: var(--bg-tint);
- border-color: transparent;
- box-shadow: none;
}
/* ── Badge (배경 틴트 + 텍스트, 좌측바 없음) ── */
diff --git a/apps/web/src/lib/api.gen.ts b/apps/web/src/lib/api.gen.ts
index d4c4ec7..075170a 100644
--- a/apps/web/src/lib/api.gen.ts
+++ b/apps/web/src/lib/api.gen.ts
@@ -818,6 +818,29 @@ export interface paths {
patch?: never;
trace?: never;
};
+ "/personas/sources/upload": {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: never;
+ };
+ get?: never;
+ put?: never;
+ /**
+ * Upload Persona Source Route
+ * @description 자유 양식 엑셀/CSV 업로드 → 텍스트 변환 → 기존 source 등록 경로 재사용 (P4).
+ *
+ * 업로드 원본 바이트는 이 핸들러 메모리에서만 파싱하고 저장하지 않는다(원본 파기).
+ * 파생 텍스트만 기존 마스킹·hash-only 증거·sanitized chunk 경로로 등록된다.
+ */
+ post: operations["upload_persona_source_route_personas_sources_upload_post"];
+ delete?: never;
+ options?: never;
+ head?: never;
+ patch?: never;
+ trace?: never;
+ };
"/personas/{persona_id}": {
parameters: {
query?: never;
@@ -1972,6 +1995,27 @@ export interface components {
*/
file: string;
};
+ /** Body_upload_persona_source_route_personas_sources_upload_post */
+ Body_upload_persona_source_route_personas_sources_upload_post: {
+ /**
+ * File
+ * Format: binary
+ */
+ file: string;
+ /**
+ * Source Kind
+ * @default mixed_notes
+ * @enum {string}
+ */
+ source_kind: "client_record" | "textbook_guide" | "mixed_notes";
+ /**
+ * Source Note
+ * @default
+ */
+ source_note: string;
+ /** Title */
+ title?: string | null;
+ };
/** ChunkOut */
ChunkOut: {
/** Behavior Cue */
@@ -2433,6 +2477,11 @@ export interface components {
persona_code: string;
/** Persona Name */
persona_name: string;
+ /**
+ * Rapport Percent
+ * @default 0
+ */
+ rapport_percent: number;
/**
* Review Ready Sessions
* @default 0
@@ -3467,14 +3516,22 @@ export interface components {
SessionDetailResponse: {
/** Case Id */
case_id: string;
+ /**
+ * Duration Limit Seconds
+ * @default 0
+ */
+ duration_limit_seconds: number;
/** Effective Openness */
effective_openness: number;
/** Ended At */
ended_at?: string | null;
+ /** Goal Stages */
+ goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/** Persona Code */
persona_code: string;
/** Persona Name */
persona_name: string;
+ progress?: components["schemas"]["SessionProgress"] | null;
/**
* Review Ready
* @default false
@@ -3498,6 +3555,11 @@ export interface components {
theory_mode: string;
/** Turns */
turns?: components["schemas"]["SessionDetailTurn"][];
+ /**
+ * Warning Before End Seconds
+ * @default 0
+ */
+ warning_before_end_seconds: number;
};
/** SessionDetailTurn */
SessionDetailTurn: {
@@ -3575,6 +3637,34 @@ export interface components {
*/
turns_evaluated: number;
};
+ /**
+ * SessionProgress
+ * @description 회기 진행 상세(P2). 내부 원값 명칭 대신 학습자-안전 파생 %만 노출한다.
+ */
+ SessionProgress: {
+ /**
+ * Openness Percent
+ * @default 0
+ */
+ openness_percent: number;
+ /**
+ * Rapport Delta Percent
+ * @default 0
+ */
+ rapport_delta_percent: number;
+ /**
+ * Rapport Percent
+ * @default 0
+ */
+ rapport_percent: number;
+ /**
+ * Resistance Percent
+ * @default 0
+ */
+ resistance_percent: number;
+ /** Stages */
+ stages?: components["schemas"]["SessionStageProgress"][];
+ };
/** SessionReviewResponse */
SessionReviewResponse: {
/** Audiourl */
@@ -3656,8 +3746,36 @@ export interface components {
/** Title */
title: string;
};
+ /**
+ * SessionStageProgress
+ * @description 단계별 누적 게이지(P2). 상태머신 결정론 수치의 파생값만 담는다.
+ */
+ SessionStageProgress: {
+ /**
+ * Achieved
+ * @default false
+ */
+ achieved: boolean;
+ /**
+ * Is Goal
+ * @default false
+ */
+ is_goal: boolean;
+ /**
+ * Percent
+ * @default 0
+ */
+ percent: number;
+ /**
+ * Stage
+ * @enum {string}
+ */
+ stage: "라포" | "탐색" | "개입" | "정리";
+ };
/** SessionStartRequest */
SessionStartRequest: {
+ /** Goal Stages */
+ goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/**
* Persona Code
* @example P1
@@ -3679,8 +3797,15 @@ export interface components {
* @default false
*/
degraded: boolean;
+ /**
+ * Duration Limit Seconds
+ * @default 0
+ */
+ duration_limit_seconds: number;
/** Effective Openness */
effective_openness: number;
+ /** Goal Stages */
+ goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
/** Recall Summary */
recall_summary?: string | null;
/** Session Id */
@@ -3692,6 +3817,16 @@ export interface components {
* @enum {string}
*/
stage: "라포" | "탐색" | "개입" | "정리";
+ /**
+ * Started At
+ * @default
+ */
+ started_at: string;
+ /**
+ * Warning Before End Seconds
+ * @default 0
+ */
+ warning_before_end_seconds: number;
};
/** SessionTeacherReviewStatus */
SessionTeacherReviewStatus: {
@@ -4057,8 +4192,9 @@ export interface components {
/**
* Appropriateness
* @default neutral
+ * @enum {string}
*/
- appropriateness: string;
+ appropriateness: "pos" | "warn" | "neutral";
/** Appropriateness Note */
appropriateness_note?: string | null;
/** Client State Read */
@@ -4117,6 +4253,7 @@ export interface components {
effective_openness: number;
/** Output Error */
output_error?: string | null;
+ progress?: components["schemas"]["SessionProgress"] | null;
/**
* Safety Flagged
* @default false
@@ -5915,6 +6052,42 @@ export interface operations {
};
};
};
+ upload_persona_source_route_personas_sources_upload_post: {
+ parameters: {
+ query?: never;
+ header?: never;
+ path?: never;
+ cookie?: {
+ "__Host-vignette_sid"?: string | null;
+ vignette_sid?: string | null;
+ };
+ };
+ requestBody: {
+ content: {
+ "multipart/form-data": components["schemas"]["Body_upload_persona_source_route_personas_sources_upload_post"];
+ };
+ };
+ responses: {
+ /** @description Successful Response */
+ 201: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["PersonaSourceDocumentResponse"];
+ };
+ };
+ /** @description Validation Error */
+ 422: {
+ headers: {
+ [name: string]: unknown;
+ };
+ content: {
+ "application/json": components["schemas"]["HTTPValidationError"];
+ };
+ };
+ };
+ };
archive_persona_route_personas__persona_id__delete: {
parameters: {
query?: never;
diff --git a/apps/web/src/lib/api.ts b/apps/web/src/lib/api.ts
index 234fcf6..fcba337 100644
--- a/apps/web/src/lib/api.ts
+++ b/apps/web/src/lib/api.ts
@@ -197,6 +197,9 @@ export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateRespon
/** POST /sessions — sessions.py SessionStartResponse */
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
+/** P2 단계 누적 게이지·상세 수치 — session_read_model.SessionProgress */
+export type SessionProgress = ApiSchema<"SessionProgress">;
+export type SessionStageProgress = ApiSchema<"SessionStageProgress">;
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export type TurnResponse = ApiSchema<"TurnResponse">;
@@ -279,6 +282,8 @@ export interface SessionStreamDone {
crisis_resource?: CrisisResource | null;
conversation_stopped?: boolean;
output_error?: string | null;
+ /** P2 단계 누적 게이지·상세 수치 */
+ progress?: SessionProgress | null;
}
function safeParse(data: string): unknown {
@@ -348,6 +353,7 @@ export async function openSessionStream(
crisis_resource: parsed.crisis_resource,
conversation_stopped: parsed.conversation_stopped,
output_error: parsed.output_error,
+ progress: parsed.progress ?? null,
};
handlers.onDone?.(donePayload);
return;
@@ -419,6 +425,25 @@ export const personaReviewApi = {
api.post("/personas/drafts", payload),
createSource: (payload: PersonaSourceDocumentRequest) =>
api.post("/personas/sources", payload),
+ /** P4: 자유 양식 엑셀/CSV 업로드 → 서버 변환·등록 (원본은 서버에 저장되지 않음) */
+ uploadSource: async (
+ file: File,
+ options: { source_kind?: string; title?: string; source_note?: string } = {},
+ ) => {
+ const form = new FormData();
+ form.append("file", file);
+ if (options.source_kind) form.append("source_kind", options.source_kind);
+ if (options.title) form.append("title", options.title);
+ if (options.source_note) form.append("source_note", options.source_note);
+ const res = await fetch(joinUrl("/personas/sources/upload"), {
+ method: "POST",
+ credentials: "include",
+ headers: { Accept: "application/json" },
+ body: form,
+ });
+ if (!res.ok) throw await parseError(res);
+ return (await res.json()) as PersonaSourceDocumentResponse;
+ },
generateDraft: (payload: PersonaDraftGenerateRequest) =>
api.post("/personas/drafts/generate", payload),
getDraft: (personaId: string) =>
@@ -464,8 +489,12 @@ export const sessionApi = {
},
get: (sessionId: string) =>
api.get(`/sessions/${encodeURIComponent(sessionId)}`),
- start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
- api.post("/sessions", { persona_code, theory_mode }),
+ start: (
+ persona_code: string,
+ theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic",
+ goal_stages: SessionStage[] = [],
+ ) =>
+ api.post("/sessions", { persona_code, theory_mode, goal_stages }),
turn: (sessionId: string, text: string) =>
api.post(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>
diff --git a/apps/web/src/lib/format.ts b/apps/web/src/lib/format.ts
index 3ef5260..07569de 100644
--- a/apps/web/src/lib/format.ts
+++ b/apps/web/src/lib/format.ts
@@ -44,6 +44,35 @@ export function formatPercent(ratio: number, fractionDigits = 0): string {
return `${pct.toFixed(fractionDigits)}%`;
}
+/** nullable 0~1 비율을 화면별 빈 상태 문구와 함께 표시한다. */
+export function formatOptionalPercent(
+ ratio: number | null | undefined,
+ fallback = "-",
+ suffix = "%",
+): string {
+ if (typeof ratio !== "number" || !Number.isFinite(ratio)) return fallback;
+ return `${Math.round(clamp01(ratio) * 100)}${suffix}`;
+}
+
+/** nullable 0~1 변화량을 백분율포인트로 표시한다. */
+export function formatPercentagePointDelta(
+ delta: number | null | undefined,
+ fallback = "-",
+): string {
+ if (typeof delta !== "number" || !Number.isFinite(delta)) return fallback;
+ const sign = delta > 0 ? "+" : "";
+ return `${sign}${Math.round(delta * 100)}%p`;
+}
+
+/** -1~1 상관·라포 값을 학습자용 0~100%로 변환한다. */
+export function formatBipolarPercent(
+ value: number | null | undefined,
+ fallback = "-",
+): string {
+ if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
+ return `${Math.round(clamp01((value + 1) / 2) * 100)}%`;
+}
+
/** 변화량 표기: +6 / -3 / 0 (부호 명시). */
export function formatDelta(delta: number): string {
if (delta > 0) return `+${delta}`;
@@ -72,6 +101,33 @@ export function formatDateISO(input: string | Date): string {
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
+/** ISO/Date를 한국어 월·일·시·분으로 표시한다. */
+export function formatDateTimeKo(
+ input: string | Date | null | undefined,
+ fallback = "-",
+): string {
+ if (!input) return fallback;
+ const date = typeof input === "string" ? new Date(input) : input;
+ if (Number.isNaN(date.getTime()))
+ return typeof input === "string" ? input : fallback;
+ return date.toLocaleString("ko-KR", {
+ month: "2-digit",
+ day: "2-digit",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+/** Unix epoch 초를 한국어 월·일·시·분으로 표시한다. */
+export function formatUnixSecondsKo(
+ seconds: number | null | undefined,
+ fallback = "-",
+): string {
+ if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)
+ return fallback;
+ return formatDateTimeKo(new Date(seconds * 1000), fallback);
+}
+
/** 0~1 클램프. */
export function clamp01(v: number): number {
if (v < 0) return 0;
diff --git a/apps/web/src/lib/personaViewModel.ts b/apps/web/src/lib/personaViewModel.ts
index 30c3289..17c3543 100644
--- a/apps/web/src/lib/personaViewModel.ts
+++ b/apps/web/src/lib/personaViewModel.ts
@@ -1,5 +1,8 @@
-import type { AvatarPersona } from "../components/avatar/ClientAvatar";
-import type { PersonaSummary } from "./api";
+import type {
+ AvatarAffect,
+ AvatarPersona,
+} from "../components/avatar/ClientAvatar";
+import type { PersonaReviewStatus, PersonaSummary } from "./api";
export const DIFFICULTY_LABEL: Record = {
easy: "기초",
@@ -7,6 +10,140 @@ export const DIFFICULTY_LABEL: Record = {
hard: "고난도",
};
+const THEORY_LABEL: Record = {
+ humanistic: "인간중심",
+ cbt: "인지행동",
+ "cognitive-behavioral": "인지행동",
+ psychodynamic: "정신역동",
+ gestalt: "게슈탈트",
+ dbt: "DBT",
+};
+
+type PersonaAvatarLook = Partial & {
+ expressionBias?: AvatarAffect;
+};
+
+interface PersonaAvatarAppearance {
+ ageBand: NonNullable;
+ skinTone: string;
+ hair: NonNullable;
+ outfitColor: string;
+ eyeColor: string;
+ realism: number;
+ resistance: number;
+ accentColor?: string;
+ expressionBias?: AvatarAffect;
+}
+
+const PERSONA_AVATAR_LOOKS: Record = {
+ P1: {
+ skinTone: "#F0DDC4",
+ hair: { style: "long-straight", color: "#5A4030" },
+ outfitColor: "#7C8A92",
+ eyeColor: "#7A5A3C",
+ accentColor: "#6B5E7D",
+ expressionBias: "sad",
+ },
+ P4: {
+ skinTone: "#E5C0A0",
+ hair: { style: "long-straight", color: "#35261F" },
+ outfitColor: "#5F6F88",
+ eyeColor: "#2F3438",
+ accentColor: "#3B6E8F",
+ expressionBias: "anxious",
+ },
+ P5: {
+ skinTone: "#D7AD8D",
+ hair: { style: "messy", color: "#28231F" },
+ outfitColor: "#6F766F",
+ eyeColor: "#24292A",
+ accentColor: "#7D8784",
+ expressionBias: "guarded",
+ },
+ P6: {
+ skinTone: "#E6BE9A",
+ hair: { style: "soft-wave", color: "#2D241F" },
+ outfitColor: "#776F85",
+ eyeColor: "#2E2B35",
+ accentColor: "#6B5E7D",
+ expressionBias: "conflicted",
+ },
+ P7: {
+ skinTone: "#D5AA88",
+ hair: { style: "side-part", color: "#23211E" },
+ outfitColor: "#596367",
+ eyeColor: "#25292B",
+ accentColor: "#6F7777",
+ expressionBias: "tired",
+ },
+};
+
+export const PENDING_PERSONA_AVATAR_APPEARANCE = {
+ ageBand: "adult",
+ skinTone: "#D7CCC3",
+ hair: { style: "short", color: "#7A7068" },
+ outfitColor: "#6F7777",
+ eyeColor: "#2B2B2B",
+} satisfies Partial;
+
+export function personaAvatarLook(code: string): PersonaAvatarLook {
+ return PERSONA_AVATAR_LOOKS[code.trim().toUpperCase()] ?? {};
+}
+
+export function personaBaselineExpression(persona: PersonaSummary | null): AvatarAffect {
+ if (!persona) return "neutral";
+ return personaAvatarLook(persona.code).expressionBias ?? "neutral";
+}
+
+export function personaAvatarAppearance(persona: PersonaSummary): PersonaAvatarAppearance {
+ const look = personaAvatarLook(persona.code);
+ const sex = demographicText(persona.demographics.sex);
+ const isMale = sex === "male" || sex === "남성";
+ const ageBand = personaAgeBand(persona);
+ return {
+ ageBand,
+ skinTone: look.skinTone ?? (isMale ? "#D8B18F" : "#E3BE9B"),
+ hair: look.hair ?? {
+ style: ageBand === "teen" ? "long-straight" : isMale ? "short" : "bob",
+ color: isMale ? "#25221F" : "#31251E",
+ },
+ outfitColor: look.outfitColor ?? (isMale ? "#5E676A" : "#6A6874"),
+ eyeColor: look.eyeColor ?? "#2B2B2B",
+ accentColor: look.accentColor,
+ realism: 0.4,
+ resistance:
+ persona.difficulty === "hard"
+ ? 0.68
+ : persona.difficulty === "moderate"
+ ? 0.45
+ : 0.3,
+ expressionBias: look.expressionBias,
+ };
+}
+
+export function personaDifficultyLabel(value: string): string {
+ return DIFFICULTY_LABEL[value] ?? value;
+}
+
+export function personaTheoryLabel(value: string): string {
+ return THEORY_LABEL[value] ?? value;
+}
+
+export function personaReviewStatusLabel(status: PersonaReviewStatus): string {
+ if (status === "review") return "검수 대기";
+ if (status === "draft") return "수정 대기";
+ if (status === "approved") return "승인됨";
+ return "보관됨";
+}
+
+export function personaReviewTone(
+ status: PersonaReviewStatus,
+): "accent" | "neutral" | "warn" {
+ if (status === "review") return "accent";
+ if (status === "draft") return "warn";
+ return "neutral";
+}
+
export function isUsablePersona(persona: PersonaSummary): boolean {
return !persona.degraded && persona.source === "database";
}
diff --git a/apps/web/src/lib/runtimeDiagnostics.ts b/apps/web/src/lib/runtimeDiagnostics.ts
new file mode 100644
index 0000000..78d1f00
--- /dev/null
+++ b/apps/web/src/lib/runtimeDiagnostics.ts
@@ -0,0 +1,9 @@
+/** 현재 문서를 실행 중인 Vite 진입 번들 이름을 진단 정보로 반환한다. */
+export function runtimeAssetLabel(): string {
+ if (typeof document === "undefined") return "unknown";
+ const script = Array.from(document.scripts)
+ .map((item) => item.getAttribute("src") ?? "")
+ .find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
+ if (!script) return "unknown";
+ return script.split("/").pop() ?? script;
+}
diff --git a/apps/web/src/lib/theme.ts b/apps/web/src/lib/theme.ts
index d0c7c30..7cc043a 100644
--- a/apps/web/src/lib/theme.ts
+++ b/apps/web/src/lib/theme.ts
@@ -1,6 +1,7 @@
export type AppTheme = "light" | "dark";
export const THEME_KEY = "vignette.theme";
+const themeListeners = new Set<() => void>();
export function readInitialTheme(): AppTheme {
try {
@@ -14,12 +15,26 @@ export function readInitialTheme(): AppTheme {
}
export function applyTheme(theme: AppTheme) {
+ const changed = document.documentElement.getAttribute("data-theme") !== theme;
document.documentElement.setAttribute("data-theme", theme);
try {
localStorage.setItem(THEME_KEY, theme);
} catch {
/* 저장 실패는 렌더링을 막지 않는다. */
}
+ if (changed) themeListeners.forEach((listener) => listener());
+}
+
+/** React와 비 React 진입점이 함께 쓰는 현재 적용 테마 snapshot. */
+export function readAppliedTheme(): AppTheme {
+ const applied = document.documentElement.getAttribute("data-theme");
+ return applied === "light" || applied === "dark" ? applied : readInitialTheme();
+}
+
+/** 앱 전역 테마 store 구독. 테마 상태의 유일한 알림 경로다. */
+export function subscribeTheme(listener: () => void) {
+ themeListeners.add(listener);
+ return () => themeListeners.delete(listener);
}
export function initTheme() {
diff --git a/apps/web/src/lib/useTheme.ts b/apps/web/src/lib/useTheme.ts
new file mode 100644
index 0000000..d5c770f
--- /dev/null
+++ b/apps/web/src/lib/useTheme.ts
@@ -0,0 +1,17 @@
+import { useCallback, useSyncExternalStore } from "react";
+import {
+ applyTheme,
+ readAppliedTheme,
+ subscribeTheme,
+ type AppTheme,
+} from "./theme";
+
+/**
+ * 전역 테마 hook. Topbar와 Settings가 같은 external store를 구독한다.
+ * 반환 형태는 기존 Settings 계약([dark, setDark])을 유지한다.
+ */
+export function useTheme(): [boolean, (next: boolean) => void] {
+ const theme = useSyncExternalStore(subscribeTheme, readAppliedTheme, (): AppTheme => "dark");
+ const setDark = useCallback((next: boolean) => applyTheme(next ? "dark" : "light"), []);
+ return [theme === "dark", setDark];
+}
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 06f11b2..e42bf3f 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -6,6 +6,7 @@ import "./styles/tokens.css";
import "./styles/global.css";
import "./components/ui/ui.css";
import "./components/shell/shell.css";
+import "./components/auth/auth-shell.css";
import App from "./App";
import { initTheme } from "./lib/theme";
diff --git a/apps/web/src/pages/Admin.tsx b/apps/web/src/pages/Admin.tsx
index 2b56de9..09502ed 100644
--- a/apps/web/src/pages/Admin.tsx
+++ b/apps/web/src/pages/Admin.tsx
@@ -8,8 +8,25 @@ import {
type ErrorInfo,
type ReactNode,
} from "react";
+import {
+ flexRender,
+ getCoreRowModel,
+ getSortedRowModel,
+ useReactTable,
+ type ColumnDef,
+ type SortingState,
+} from "@tanstack/react-table";
import { AppShell } from "../components/shell/AppShell";
-import { Badge, Button, Dot, Icon, Kicker, ProgressBar } from "../components/ui";
+import {
+ Badge,
+ Button,
+ Dot,
+ EmptyState as UiEmptyState,
+ Icon,
+ Kicker,
+ ProgressBar,
+ surfaceClassName,
+} from "../components/ui";
import {
adminApi,
adminUsersApi,
@@ -25,30 +42,45 @@ import {
type AdminUsersResponse,
} from "../lib/api";
import { useAuth } from "../lib/auth";
+import { formatUnixSecondsKo } from "../lib/format";
+import { runtimeAssetLabel } from "../lib/runtimeDiagnostics";
+import {
+ normalizeAdminTicketsResponse,
+ normalizeAdminUsersResponse,
+ type AdminDataWarning,
+} from "./admin/dataNormalization";
import "./admin/admin-console.css";
export type AdminSection = "overview" | "users" | "access" | "tickets";
type UserDraft = Pick<
AdminManagedUser,
- "display_name" | "role" | "admin_access" | "account_status" | "affiliation" | "cohort_ids"
+ | "display_name"
+ | "role"
+ | "admin_access"
+ | "account_status"
+ | "affiliation"
+ | "cohort_ids"
>;
-type NewUserDraft = Required> &
- Pick;
+type NewUserDraft = Required<
+ Pick
+> &
+ Pick<
+ UserDraft,
+ "admin_access" | "account_status" | "affiliation" | "cohort_ids"
+ >;
type UserTab = "approval" | "manage" | "register" | "activity";
type AccessTab = "roles" | "groups" | "matrix";
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
-type AdminDataWarning = {
- key: string;
- message: string;
- detail: string;
-};
-
const MAX_RENDERED_USERS = 40;
+const MAX_RENDERED_ACTIVE_TICKETS = 24;
const MAX_RESOLVED_TICKET_HISTORY = 6;
-const TICKET_STATUS_OPTIONS: Array<{ value: TicketStatusFilter; label: string }> = [
+const TICKET_STATUS_OPTIONS: Array<{
+ value: TicketStatusFilter;
+ label: string;
+}> = [
{ value: "all", label: "전체 상태" },
{ value: "open", label: "미해결" },
{ value: "triaged", label: "분류됨" },
@@ -56,7 +88,10 @@ const TICKET_STATUS_OPTIONS: Array<{ value: TicketStatusFilter; label: string }>
{ value: "resolved", label: "해결" },
{ value: "closed", label: "종결" },
];
-const TICKET_CATEGORY_OPTIONS: Array<{ value: TicketCategoryFilter; label: string }> = [
+const TICKET_CATEGORY_OPTIONS: Array<{
+ value: TicketCategoryFilter;
+ label: string;
+}> = [
{ value: "all", label: "전체 카테고리" },
{ value: "account_access", label: "계정/권한" },
{ value: "session_review", label: "세션/리뷰" },
@@ -65,7 +100,10 @@ const TICKET_CATEGORY_OPTIONS: Array<{ value: TicketCategoryFilter; label: strin
{ value: "safety", label: "안전" },
{ value: "other", label: "기타" },
];
-const TICKET_PRIORITY_OPTIONS: Array<{ value: TicketPriorityFilter; label: string }> = [
+const TICKET_PRIORITY_OPTIONS: Array<{
+ value: TicketPriorityFilter;
+ label: string;
+}> = [
{ value: "all", label: "전체 우선순위" },
{ value: "urgent", label: "긴급" },
{ value: "high", label: "높음" },
@@ -124,10 +162,25 @@ const GROUP_POLICIES = [
const PERMISSION_MATRIX = [
{ resource: "운영 콘솔", admin: "전체", teacher: "없음", learner: "없음" },
- { resource: "사용자 관리", admin: "등록/수정", teacher: "담당자 읽기", learner: "본인만" },
- { resource: "회기 리뷰", admin: "감사 로그", teacher: "담당 코호트", learner: "본인 회기" },
+ {
+ resource: "사용자 관리",
+ admin: "등록/수정",
+ teacher: "담당자 읽기",
+ learner: "본인만",
+ },
+ {
+ resource: "회기 리뷰",
+ admin: "감사 로그",
+ teacher: "담당 코호트",
+ learner: "본인 회기",
+ },
{ resource: "AI 운영 설정", admin: "수정", teacher: "없음", learner: "없음" },
- { resource: "안전 알림", admin: "전체", teacher: "담당 코호트", learner: "없음" },
+ {
+ resource: "안전 알림",
+ admin: "전체",
+ teacher: "담당 코호트",
+ learner: "없음",
+ },
];
function toneOf(status: AdminHealthStatus): "pos" | "warn" | "crit" {
@@ -158,7 +211,8 @@ function environmentLabel(value: string): string {
function engineModeLabel(value: string): string {
if (value === "claude_cli") return "Claude CLI 게이트웨이";
- if (value === "claude_api" || value === "messages_api") return "Anthropic API";
+ if (value === "claude_api" || value === "messages_api")
+ return "Anthropic API";
if (value === "openai") return "OpenAI 호환";
if (value === "solar") return "Solar";
return value;
@@ -170,7 +224,9 @@ function roleText(role: AdminManagedUser["role"]): string {
return "학습자";
}
-function roleBadgeTone(role: AdminManagedUser["role"]): "accent" | "neutral" | "warn" {
+function roleBadgeTone(
+ role: AdminManagedUser["role"],
+): "accent" | "neutral" | "warn" {
if (role === "admin") return "warn";
if (role === "teacher") return "accent";
return "neutral";
@@ -182,7 +238,9 @@ function accountStatusText(status: AdminManagedUser["account_status"]): string {
return "승인 대기";
}
-function accountStatusTone(status: AdminManagedUser["account_status"]): "accent" | "neutral" | "warn" | "crit" {
+function accountStatusTone(
+ status: AdminManagedUser["account_status"],
+): "accent" | "neutral" | "warn" | "crit" {
if (status === "approved") return "accent";
if (status === "suspended") return "crit";
return "warn";
@@ -194,13 +252,7 @@ function storeLabel(data: AdminUsersResponse | null): string {
}
function dateTimeLabel(seconds?: number | null): string {
- if (!seconds || !Number.isFinite(seconds) || seconds <= 0) return "-";
- return new Date(seconds * 1000).toLocaleString("ko-KR", {
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- });
+ return formatUnixSecondsKo(seconds);
}
function countLabel(value: number): string {
@@ -228,10 +280,13 @@ function usageBudgetLabel(data: AdminUsageResponse): string {
function usageBudgetDetail(data: AdminUsageResponse): string {
const { budget } = data;
- if (budget.status === "disabled") return "ADMIN_USAGE_BUDGET_USD가 설정되지 않았습니다.";
+ if (budget.status === "disabled")
+ return "ADMIN_USAGE_BUDGET_USD가 설정되지 않았습니다.";
const pct = Math.round(budget.used_ratio * 100);
const remaining =
- budget.remaining_usd === null ? "" : ` · 잔여 ${costLabel(budget.remaining_usd)}`;
+ budget.remaining_usd === null
+ ? ""
+ : ` · 잔여 ${costLabel(budget.remaining_usd)}`;
return `${costLabel(data.cost_usd)} / ${costLabel(budget.limit_usd)} · ${pct}% 사용${remaining}`;
}
@@ -240,7 +295,9 @@ function rateLabel(value: number): string {
return `${Math.round(value * 1000) / 10}%`;
}
-function evaluatorCache(data: AdminUsageResponse): AdminUsageResponse["evaluator_cache"] {
+function evaluatorCache(
+ data: AdminUsageResponse,
+): AdminUsageResponse["evaluator_cache"] {
return (
data.evaluator_cache ?? {
enabled: false,
@@ -267,7 +324,9 @@ function evaluatorCacheDetail(data: AdminUsageResponse): string {
return `${countLabel(cache.hits)} / ${countLabel(cache.requests)} hit · entry ${countLabel(cache.entries)}개 · 저장 ${countLabel(cache.stores)}회`;
}
-function usageDailyCost(data: AdminUsageResponse): NonNullable {
+function usageDailyCost(
+ data: AdminUsageResponse,
+): NonNullable {
return data.daily_cost ?? [];
}
@@ -317,7 +376,9 @@ function ticketCategoryLabel(category: AdminSupportTicket["category"]): string {
return "기타";
}
-function ticketTone(ticket: AdminSupportTicket): "accent" | "neutral" | "warn" | "crit" {
+function ticketTone(
+ ticket: AdminSupportTicket,
+): "accent" | "neutral" | "warn" | "crit" {
if (!ticketIsActive(ticket)) return "neutral";
if (ticket.priority === "urgent") return "crit";
if (ticket.priority === "high") return "warn";
@@ -328,6 +389,85 @@ function shortTicketId(ticketId: string): string {
return ticketId.replace(/-/g, "").slice(0, 8).toUpperCase();
}
+interface TicketActionsProps {
+ ticket: AdminSupportTicket;
+ duplicateParentId: string | null;
+ canLinkDuplicate: boolean;
+ onUpdateStatus: (
+ ticket: AdminSupportTicket,
+ status: AdminSupportTicket["status"],
+ ) => Promise;
+ onUpdateParent: (
+ ticket: AdminSupportTicket,
+ parentTicketId: string | null,
+ ) => Promise;
+}
+
+function TicketActions({
+ ticket,
+ duplicateParentId,
+ canLinkDuplicate,
+ onUpdateStatus,
+ onUpdateParent,
+}: TicketActionsProps) {
+ const [pending, setPending] = useState(false);
+ const pendingRef = useRef(false);
+
+ const run = (action: () => Promise) => {
+ if (pendingRef.current) return;
+ pendingRef.current = true;
+ // 네이티브 입력 dispatch 안에서 Admin 루트 렌더를 동기 flush하지 않는다.
+ // ref가 즉시 중복 요청을 막고, 시각 상태만 다음 태스크에서 반영한다.
+ const pendingTimer = window.setTimeout(() => setPending(true), 0);
+ void action().finally(() => {
+ window.clearTimeout(pendingTimer);
+ pendingRef.current = false;
+ setPending(false);
+ });
+ };
+
+ return (
+
+ {canLinkDuplicate ? (
+ run(() => onUpdateParent(ticket, duplicateParentId))}
+ disabled={pending}
+ >
+ 연결
+
+ ) : null}
+ {ticket.parent_ticket_id ? (
+ run(() => onUpdateParent(ticket, null))}
+ disabled={pending}
+ >
+ 해제
+
+ ) : null}
+ run(() => onUpdateStatus(ticket, "in_progress"))}
+ disabled={pending || ticket.status === "in_progress"}
+ >
+ 처리 중
+
+ run(() => onUpdateStatus(ticket, "resolved"))}
+ disabled={pending}
+ >
+ 해결
+
+
+ );
+}
+
function initialOf(user: AdminManagedUser): string {
const label = user.display_name.trim() || user.email;
return Array.from(label)[0]?.toUpperCase() ?? "?";
@@ -344,296 +484,31 @@ function parseCohorts(value: string): string[] {
.filter(Boolean);
}
-function asRecord(value: unknown): Record {
- return value !== null && typeof value === "object" ? (value as Record) : {};
-}
-
-function stringField(
- source: Record,
- field: string,
- fallback: string,
- warnings: AdminDataWarning[],
- label: string,
-): string {
- const value = source[field];
- if (typeof value === "string") return value;
- if (value == null) {
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field} 값이 비어 있어 기본값으로 표시합니다.`,
- detail: `${field}=null`,
- });
- return fallback;
- }
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field} 타입이 잘못되어 문자열로 변환했습니다.`,
- detail: `${field}=${typeof value}`,
- });
- return String(value);
-}
-
-function booleanField(
- source: Record,
- field: string,
- fallback: boolean,
- warnings: AdminDataWarning[],
- label: string,
-): boolean {
- const value = source[field];
- if (typeof value === "boolean") return value;
- if (value == null) {
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field} 값이 비어 있어 ${fallback ? "true" : "false"}로 표시합니다.`,
- detail: `${field}=null`,
- });
- return fallback;
- }
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field} 타입이 잘못되어 bool 기본값으로 표시합니다.`,
- detail: `${field}=${typeof value}`,
- });
- return fallback;
-}
-
-function numberField(
- source: Record,
- field: string,
- fallback: number,
- warnings: AdminDataWarning[],
- label: string,
-): number {
- const value = source[field];
- if (typeof value === "number" && Number.isFinite(value)) return value;
- if (typeof value === "string" && value.trim() !== "") {
- const parsed = Number(value);
- if (Number.isFinite(parsed)) {
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field}가 문자열이라 숫자로 변환했습니다.`,
- detail: `${field}=string`,
- });
- return parsed;
- }
- }
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field} 숫자 값이 없어 ${fallback}으로 표시합니다.`,
- detail: `${field}=${value == null ? "null" : typeof value}`,
- });
- return fallback;
-}
-
-function cohortIdsField(
- source: Record,
- warnings: AdminDataWarning[],
- label: string,
-): string[] {
- const value = source.cohort_ids;
- if (!Array.isArray(value)) {
- warnings.push({
- key: `${label}.cohort_ids`,
- message: `${label} 응답에서 cohort_ids가 배열이 아니라 빈 코호트로 표시합니다.`,
- detail: `cohort_ids=${value == null ? "null" : typeof value}`,
- });
- return [];
- }
- const normalized = value.filter((item): item is string => typeof item === "string");
- if (normalized.length !== value.length) {
- warnings.push({
- key: `${label}.cohort_ids.items`,
- message: `${label} 응답에서 cohort_ids 내부에 문자열이 아닌 값이 있어 제외했습니다.`,
- detail: `kept=${normalized.length}, raw=${value.length}`,
- });
- }
- return normalized;
-}
-
-function numberRecordField(
- source: Record,
- field: string,
- warnings: AdminDataWarning[],
- label: string,
-): Record {
- const value = source[field];
- if (value == null) {
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field} 값이 비어 있어 빈 집계로 표시합니다.`,
- detail: `${field}=null`,
- });
- return {};
- }
- if (typeof value !== "object" || Array.isArray(value)) {
- warnings.push({
- key: `${label}.${field}`,
- message: `${label} 응답에서 ${field} 타입이 잘못되어 빈 집계로 표시합니다.`,
- detail: `${field}=${typeof value}`,
- });
- return {};
- }
- const entries = Object.entries(value as Record);
- const normalized: Record = {};
- for (const [key, raw] of entries) {
- if (typeof raw === "number" && Number.isFinite(raw)) {
- normalized[key] = raw;
- continue;
- }
- const parsed = typeof raw === "string" ? Number(raw) : Number.NaN;
- if (Number.isFinite(parsed)) {
- normalized[key] = parsed;
- warnings.push({
- key: `${label}.${field}.${key}`,
- message: `${label} 응답에서 ${field}.${key}가 문자열이라 숫자로 변환했습니다.`,
- detail: `${field}.${key}=string`,
- });
- continue;
- }
- warnings.push({
- key: `${label}.${field}.${key}`,
- message: `${label} 응답에서 ${field}.${key} 숫자 값이 없어 제외했습니다.`,
- detail: `${field}.${key}=${raw == null ? "null" : typeof raw}`,
- });
- }
- return normalized;
-}
-
-function roleField(
- source: Record,
- warnings: AdminDataWarning[],
- label: string,
-): AdminManagedUser["role"] {
- const value = source.role;
- if (value === "learner" || value === "teacher" || value === "admin") return value;
- warnings.push({
- key: `${label}.role`,
- message: `${label} 응답에서 role 값이 계약과 달라 학습자로 표시합니다.`,
- detail: `role=${String(value)}`,
- });
- return "learner";
-}
-
-function accountStatusField(
- source: Record,
- warnings: AdminDataWarning[],
- label: string,
-): AdminManagedUser["account_status"] {
- const value = source.account_status;
- if (value === "pending" || value === "approved" || value === "suspended") return value;
- warnings.push({
- key: `${label}.account_status`,
- message: `${label} 응답에서 account_status 값이 계약과 달라 승인 대기로 표시합니다.`,
- detail: `account_status=${String(value)}`,
- });
- return "pending";
-}
-
-function userSourceField(
- source: Record,
- parentSource: AdminUsersResponse["source"],
-): AdminManagedUser["source"] {
- const value = source.source;
- return value === "server_session_registry" || value === "database" ? value : parentSource;
-}
-
-function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
- response: AdminUsersResponse;
- warnings: AdminDataWarning[];
-} {
- const warnings: AdminDataWarning[] = [];
- const root = asRecord(rawResponse);
- const source: AdminUsersResponse["source"] =
- root.source === "server_session_registry" ? "server_session_registry" : "database";
- const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
- const rawUsers = Array.isArray(root.users) ? root.users : [];
-
- if (!Array.isArray(root.users)) {
- warnings.push({
- key: "admin.users",
- message: "/admin/users 응답에서 users 배열이 없어 빈 목록으로 표시합니다.",
- detail: `users=${root.users == null ? "null" : typeof root.users}`,
- });
- }
-
- const users = rawUsers.map((item, index): AdminManagedUser => {
- const sourceRecord = asRecord(item);
- const label = `users[${index}]`;
- const userId = stringField(sourceRecord, "user_id", `unknown-${index + 1}`, warnings, label);
- return {
- user_id: userId,
- email: stringField(sourceRecord, "email", userId, warnings, label),
- display_name: stringField(sourceRecord, "display_name", userId, warnings, label),
- role: roleField(sourceRecord, warnings, label),
- admin_access: booleanField(sourceRecord, "admin_access", false, warnings, label),
- super_admin: booleanField(sourceRecord, "super_admin", false, warnings, label),
- account_status: accountStatusField(sourceRecord, warnings, label),
- cohort_ids: cohortIdsField(sourceRecord, warnings, label),
- affiliation: stringField(sourceRecord, "affiliation", "", warnings, label),
- active_sessions: numberField(sourceRecord, "active_sessions", 0, warnings, label),
- created_at: numberField(sourceRecord, "created_at", 0, warnings, label),
- last_seen_at: numberField(sourceRecord, "last_seen_at", 0, warnings, label),
- source: userSourceField(sourceRecord, source),
- };
- });
-
+function userDraftFrom(user: AdminManagedUser): UserDraft {
return {
- response: {
- source,
- durable,
- users,
- },
- warnings,
+ display_name: user.display_name,
+ role: user.role,
+ admin_access: user.admin_access,
+ account_status: user.account_status,
+ affiliation: user.affiliation,
+ cohort_ids: user.cohort_ids,
};
}
-function normalizeAdminTicketsResponse(rawResponse: AdminTicketsResponse): {
- response: AdminTicketsResponse;
- warnings: AdminDataWarning[];
-} {
- const warnings: AdminDataWarning[] = [];
- const root = asRecord(rawResponse);
- const source: AdminTicketsResponse["source"] = root.source === "database" ? "database" : "unavailable";
- const durable = typeof root.durable === "boolean" ? root.durable : source === "database";
- const rawTickets = Array.isArray(root.tickets) ? root.tickets : [];
- if (!Array.isArray(root.tickets)) {
- warnings.push({
- key: "admin.tickets",
- message: "/admin/tickets 응답에서 tickets 배열이 없어 빈 목록으로 표시합니다.",
- detail: `tickets=${root.tickets == null ? "null" : typeof root.tickets}`,
- });
- }
-
- const summaryRoot = asRecord(root.summary);
- if (!root.summary || typeof root.summary !== "object" || Array.isArray(root.summary)) {
- warnings.push({
- key: "admin.tickets.summary",
- message: "/admin/tickets 응답에서 summary 객체가 없어 기본 집계로 표시합니다.",
- detail: `summary=${root.summary == null ? "null" : typeof root.summary}`,
- });
- }
-
+function replaceManagedUser(
+ current: AdminUsersResponse | null,
+ updated: AdminManagedUser,
+): AdminUsersResponse | null {
+ if (!current) return current;
return {
- response: {
- source,
- durable,
- generated_at: numberField(root, "generated_at", 0, warnings, "admin.tickets"),
- tickets: rawTickets as AdminSupportTicket[],
- summary: {
- total: numberField(summaryRoot, "total", rawTickets.length, warnings, "admin.tickets.summary"),
- open_count: numberField(summaryRoot, "open_count", 0, warnings, "admin.tickets.summary"),
- high_priority_count: numberField(summaryRoot, "high_priority_count", 0, warnings, "admin.tickets.summary"),
- stale_count: numberField(summaryRoot, "stale_count", 0, warnings, "admin.tickets.summary"),
- by_category: numberRecordField(summaryRoot, "by_category", warnings, "admin.tickets.summary"),
- by_priority: numberRecordField(summaryRoot, "by_priority", warnings, "admin.tickets.summary"),
- by_status: numberRecordField(summaryRoot, "by_status", warnings, "admin.tickets.summary"),
- },
- },
- warnings,
+ ...current,
+ users: current.users.map((user) =>
+ user.user_id === updated.user_id ? updated : user,
+ ),
};
}
+
function isOnline(seconds?: number | null): boolean {
if (!seconds || seconds <= 0) return false;
return Date.now() / 1000 - seconds < 15 * 60;
@@ -659,15 +534,6 @@ function PageHeader({ kicker, title, description, children }: PageHeaderProps) {
);
}
-function runtimeAssetLabel(): string {
- if (typeof document === "undefined") return "unknown";
- const script = Array.from(document.scripts)
- .map((item) => item.getAttribute("src") ?? "")
- .find((src) => src.includes("/assets/index-") && src.endsWith(".js"));
- if (!script) return "unknown";
- return script.split("/").pop() ?? script;
-}
-
function AdminDiagnosticPanel({
title,
body,
@@ -684,7 +550,7 @@ function AdminDiagnosticPanel({
{title}
{body}
-
+
{details.map(([key, value]) => (
{key}
@@ -706,7 +572,10 @@ interface AdminErrorBoundaryState {
errorInfo: ErrorInfo | null;
}
-class AdminErrorBoundary extends Component
{
+class AdminErrorBoundary extends Component<
+ AdminErrorBoundaryProps,
+ AdminErrorBoundaryState
+> {
state: AdminErrorBoundaryState = { error: null, errorInfo: null };
static getDerivedStateFromError(error: Error): AdminErrorBoundaryState {
@@ -732,10 +601,18 @@ class AdminErrorBoundary extends Component
);
@@ -746,7 +623,9 @@ class AdminErrorBoundary extends Component(null);
- const [diagnostic, setDiagnostic] = useState | null>(null);
+ const [diagnostic, setDiagnostic] = useState | null>(
+ null,
+ );
useEffect(() => {
setDiagnostic(null);
@@ -772,7 +651,11 @@ function AdminBlankContentProbe({ section }: { section: AdminSection }) {
);
});
- if (text.length === 0 || rootRect.height < 12 || visibleNodes.length === 0) {
+ if (
+ text.length === 0 ||
+ rootRect.height < 12 ||
+ visibleNodes.length === 0
+ ) {
setDiagnostic([
["section", section],
["path", window.location.pathname],
@@ -830,7 +713,12 @@ function AdminSectionRenderer({
body="라우터가 알 수 없는 관리자 섹션을 전달했습니다."
details={[
["section", section],
- ["path", typeof window === "undefined" ? "unknown" : window.location.pathname],
+ [
+ "path",
+ typeof window === "undefined"
+ ? "unknown"
+ : window.location.pathname,
+ ],
["asset", runtimeAssetLabel()],
]}
/>
@@ -853,13 +741,20 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [usersData, setUsersData] = useState(null);
const [usersLoading, setUsersLoading] = useState(true);
const [usersError, setUsersError] = useState(null);
- const [usersDiagnostics, setUsersDiagnostics] = useState([]);
+ const [usersDiagnostics, setUsersDiagnostics] = useState(
+ [],
+ );
const [userDrafts, setUserDrafts] = useState>({});
const [savingUserId, setSavingUserId] = useState(null);
const [newUser, setNewUser] = useState(EMPTY_NEW_USER);
const [creatingUser, setCreatingUser] = useState(false);
- const [deactivatingUserId, setDeactivatingUserId] = useState(null);
+ const [deactivatingUserId, setDeactivatingUserId] = useState(
+ null,
+ );
const [userSearch, setUserSearch] = useState("");
+ const [userSorting, setUserSorting] = useState([
+ { id: "last_seen_at", desc: true },
+ ]);
const [usage, setUsage] = useState(null);
const [usageLoading, setUsageLoading] = useState(true);
const [usageError, setUsageError] = useState(null);
@@ -869,11 +764,15 @@ export default function Admin({ section = "overview" }: AdminProps) {
const [tickets, setTickets] = useState(null);
const [ticketsLoading, setTicketsLoading] = useState(true);
const [ticketsError, setTicketsError] = useState(null);
- const [ticketsDiagnostics, setTicketsDiagnostics] = useState([]);
- const [updatingTicketId, setUpdatingTicketId] = useState(null);
- const [ticketStatusFilter, setTicketStatusFilter] = useState("all");
- const [ticketCategoryFilter, setTicketCategoryFilter] = useState("all");
- const [ticketPriorityFilter, setTicketPriorityFilter] = useState("all");
+ const [ticketsDiagnostics, setTicketsDiagnostics] = useState<
+ AdminDataWarning[]
+ >([]);
+ const [ticketStatusFilter, setTicketStatusFilter] =
+ useState("all");
+ const [ticketCategoryFilter, setTicketCategoryFilter] =
+ useState("all");
+ const [ticketPriorityFilter, setTicketPriorityFilter] =
+ useState("all");
const [ticketSearch, setTicketSearch] = useState("");
const [ticketAssignedGroup, setTicketAssignedGroup] = useState("");
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
@@ -889,7 +788,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
setHealth(next);
setUpdatedAt(new Date());
} catch (err) {
- setError(err instanceof Error ? err.message : "운영 상태를 불러오지 못했습니다.");
+ setError(
+ err instanceof Error ? err.message : "운영 상태를 불러오지 못했습니다.",
+ );
} finally {
setLoading(false);
}
@@ -900,26 +801,24 @@ export default function Admin({ section = "overview" }: AdminProps) {
setUsersError(null);
try {
const next = await adminUsersApi.list();
- const { response: normalized, warnings } = normalizeAdminUsersResponse(next);
+ const { response: normalized, warnings } =
+ normalizeAdminUsersResponse(next);
setUsersData(normalized);
setUsersDiagnostics(warnings);
setUserDrafts((current) =>
Object.fromEntries(
normalized.users.map((user) => [
user.user_id,
- current[user.user_id] ?? {
- display_name: user.display_name,
- role: user.role,
- admin_access: user.admin_access,
- account_status: user.account_status,
- affiliation: user.affiliation,
- cohort_ids: user.cohort_ids,
- },
+ current[user.user_id] ?? userDraftFrom(user),
]),
),
);
} catch (err) {
- setUsersError(err instanceof Error ? err.message : "사용자 목록을 불러오지 못했습니다.");
+ setUsersError(
+ err instanceof Error
+ ? err.message
+ : "사용자 목록을 불러오지 못했습니다.",
+ );
setUsersDiagnostics([]);
} finally {
setUsersLoading(false);
@@ -932,7 +831,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
try {
setUsage(await adminApi.usage(7));
} catch (err) {
- setUsageError(err instanceof Error ? err.message : "비용 사용량을 불러오지 못했습니다.");
+ setUsageError(
+ err instanceof Error
+ ? err.message
+ : "비용 사용량을 불러오지 못했습니다.",
+ );
} finally {
setUsageLoading(false);
}
@@ -944,7 +847,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
try {
setUptime(await adminApi.uptime(24));
} catch (err) {
- setUptimeError(err instanceof Error ? err.message : "업타임 이력을 불러오지 못했습니다.");
+ setUptimeError(
+ err instanceof Error
+ ? err.message
+ : "업타임 이력을 불러오지 못했습니다.",
+ );
} finally {
setUptimeLoading(false);
}
@@ -952,7 +859,6 @@ export default function Admin({ section = "overview" }: AdminProps) {
const loadTickets = useCallback(async () => {
setTicketsLoading(true);
- setTicketsError(null);
try {
const filters: AdminTicketFilters = {
status: ticketStatusFilter === "all" ? "" : ticketStatusFilter,
@@ -964,11 +870,14 @@ export default function Admin({ section = "overview" }: AdminProps) {
windowDays: 30,
};
const next = await adminApi.tickets(filters);
- const { response: normalized, warnings } = normalizeAdminTicketsResponse(next);
+ const { response: normalized, warnings } =
+ normalizeAdminTicketsResponse(next);
setTickets(normalized);
setTicketsDiagnostics(warnings);
} catch (err) {
- setTicketsError(err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.");
+ setTicketsError(
+ err instanceof Error ? err.message : "운영 티켓을 불러오지 못했습니다.",
+ );
setTicketsDiagnostics([]);
} finally {
setTicketsLoading(false);
@@ -987,11 +896,20 @@ export default function Admin({ section = "overview" }: AdminProps) {
void loadUsers();
void loadUsage();
void loadUptime();
+ }, [loadHealth, loadUptime, loadUsage, loadUsers]);
+
+ useEffect(() => {
void loadTickets();
- }, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
+ }, [loadTickets]);
const refreshAll = useCallback(async () => {
- await Promise.all([loadHealth(), loadUsers(), loadUsage(), loadUptime(), loadTickets()]);
+ await Promise.all([
+ loadHealth(),
+ loadUsers(),
+ loadUsage(),
+ loadUptime(),
+ loadTickets(),
+ ]);
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
const updateDraft = (userId: string, patch: Partial) => {
@@ -1018,29 +936,17 @@ export default function Admin({ section = "overview" }: AdminProps) {
setUsersError(null);
try {
const updated = await adminUsersApi.update(user.user_id, draft);
- setUsersData((current) =>
- current
- ? {
- ...current,
- users: current.users.map((item) =>
- item.user_id === updated.user_id ? updated : item,
- ),
- }
- : current,
- );
+ setUsersData((current) => replaceManagedUser(current, updated));
setUserDrafts((current) => ({
...current,
- [updated.user_id]: {
- display_name: updated.display_name,
- role: updated.role,
- admin_access: updated.admin_access,
- account_status: updated.account_status,
- affiliation: updated.affiliation,
- cohort_ids: updated.cohort_ids,
- },
+ [updated.user_id]: userDraftFrom(updated),
}));
} catch (err) {
- setUsersError(err instanceof Error ? err.message : "사용자 정보를 저장하지 못했습니다.");
+ setUsersError(
+ err instanceof Error
+ ? err.message
+ : "사용자 정보를 저장하지 못했습니다.",
+ );
} finally {
setSavingUserId(null);
}
@@ -1067,7 +973,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
await loadUsers();
setUserTab("manage");
} catch (err) {
- setUsersError(err instanceof Error ? err.message : "사용자를 등록하지 못했습니다.");
+ setUsersError(
+ err instanceof Error ? err.message : "사용자를 등록하지 못했습니다.",
+ );
} finally {
setCreatingUser(false);
}
@@ -1083,29 +991,17 @@ export default function Admin({ section = "overview" }: AdminProps) {
const updated = await adminUsersApi.update(user.user_id, {
account_status: accountStatus,
});
- setUsersData((current) =>
- current
- ? {
- ...current,
- users: current.users.map((item) =>
- item.user_id === updated.user_id ? updated : item,
- ),
- }
- : current,
- );
+ setUsersData((current) => replaceManagedUser(current, updated));
setUserDrafts((current) => ({
...current,
- [updated.user_id]: {
- display_name: updated.display_name,
- role: updated.role,
- admin_access: updated.admin_access,
- account_status: updated.account_status,
- affiliation: updated.affiliation,
- cohort_ids: updated.cohort_ids,
- },
+ [updated.user_id]: userDraftFrom(updated),
}));
} catch (err) {
- setUsersError(err instanceof Error ? err.message : "계정 승인 상태를 저장하지 못했습니다.");
+ setUsersError(
+ err instanceof Error
+ ? err.message
+ : "계정 승인 상태를 저장하지 못했습니다.",
+ );
} finally {
setSavingUserId(null);
}
@@ -1120,7 +1016,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
current
? {
...current,
- users: current.users.filter((item) => item.user_id !== user.user_id),
+ users: current.users.filter(
+ (item) => item.user_id !== user.user_id,
+ ),
}
: current,
);
@@ -1130,7 +1028,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
return next;
});
} catch (err) {
- setUsersError(err instanceof Error ? err.message : "사용자를 비활성화하지 못했습니다.");
+ setUsersError(
+ err instanceof Error
+ ? err.message
+ : "사용자를 비활성화하지 못했습니다.",
+ );
} finally {
setDeactivatingUserId(null);
}
@@ -1140,10 +1042,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
ticket: AdminSupportTicket,
status: AdminSupportTicket["status"],
) => {
- setUpdatingTicketId(ticket.ticket_id);
- setTicketsError(null);
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, { status });
+ setTicketsError(null);
setTickets((current) =>
current
? {
@@ -1156,9 +1057,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
await loadTickets();
} catch (err) {
- setTicketsError(err instanceof Error ? err.message : "티켓 상태를 저장하지 못했습니다.");
- } finally {
- setUpdatingTicketId(null);
+ setTicketsError(
+ err instanceof Error ? err.message : "티켓 상태를 저장하지 못했습니다.",
+ );
}
};
@@ -1166,12 +1067,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
ticket: AdminSupportTicket,
parentTicketId: string | null,
) => {
- setUpdatingTicketId(ticket.ticket_id);
- setTicketsError(null);
try {
const updated = await adminApi.updateTicket(ticket.ticket_id, {
parent_ticket_id: parentTicketId ?? "",
});
+ setTicketsError(null);
setTickets((current) =>
current
? {
@@ -1184,9 +1084,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
await loadTickets();
} catch (err) {
- setTicketsError(err instanceof Error ? err.message : "중복 연결을 저장하지 못했습니다.");
- } finally {
- setUpdatingTicketId(null);
+ setTicketsError(
+ err instanceof Error ? err.message : "중복 연결을 저장하지 못했습니다.",
+ );
}
};
@@ -1195,7 +1095,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
const counts = useMemo(
() => ({
ok: services.filter((service) => service.status === "ok").length,
- degraded: services.filter((service) => service.status === "degraded").length,
+ degraded: services.filter((service) => service.status === "degraded")
+ .length,
down: services.filter((service) => service.status === "down").length,
total: services.length,
}),
@@ -1213,7 +1114,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
[users],
);
const activeSessions = useMemo(
- () => users.reduce((sum, user) => sum + Math.max(0, user.active_sessions), 0),
+ () =>
+ users.reduce((sum, user) => sum + Math.max(0, user.active_sessions), 0),
[users],
);
const accountCounts = useMemo(
@@ -1247,6 +1149,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
() => (tickets?.tickets ?? []).filter(ticketIsActive),
[tickets],
);
+ const visibleActiveTickets = useMemo(
+ () => activeTickets.slice(0, MAX_RENDERED_ACTIVE_TICKETS),
+ [activeTickets],
+ );
const recentResolvedTickets = useMemo(
() =>
(tickets?.tickets ?? [])
@@ -1297,7 +1203,254 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
}, [userSearch, users]);
const hasUserSearch = userSearch.trim().length > 0;
- const visibleUsers = filteredUsers.slice(0, MAX_RENDERED_USERS);
+ const userColumns: ColumnDef[] = [
+ {
+ id: "identity",
+ accessorFn: (user) => `${user.display_name} ${user.email}`,
+ header: "사용자",
+ cell: ({ row }) => {
+ const user = row.original;
+ const draft = userDrafts[user.user_id] ?? userDraftFrom(user);
+ return (
+
+
+ {initialOf(user)}
+
+
+
+ updateDraft(user.user_id, {
+ display_name: event.target.value,
+ })
+ }
+ disabled={!usersWritable || savingUserId === user.user_id}
+ aria-label={`${user.email} 표시 이름`}
+ />
+ {user.email}
+
+
+ );
+ },
+ },
+ {
+ id: "role",
+ accessorFn: (user) => user.role,
+ header: "역할",
+ cell: ({ row }) => {
+ const user = row.original;
+ const draft = userDrafts[user.user_id] ?? user;
+ return (
+
+
+ updateDraft(user.user_id, {
+ role: event.target.value as AdminManagedUser["role"],
+ })
+ }
+ disabled={
+ !usersWritable ||
+ savingUserId === user.user_id ||
+ (user.role === "admin" && !canGrantAdminAccess)
+ }
+ aria-label={`${user.email} 역할`}
+ >
+ 학습자
+ 교수자
+
+ 관리자
+
+
+
+
+ {roleText(user.role)}
+
+ {user.super_admin ? 슈퍼 : null}
+
+
+ );
+ },
+ },
+ {
+ id: "admin_access",
+ accessorFn: (user) => Number(user.admin_access),
+ header: "관리 권한",
+ cell: ({ row }) => {
+ const user = row.original;
+ const draft = userDrafts[user.user_id] ?? user;
+ return (
+
+
+ updateDraft(user.user_id, {
+ admin_access: event.target.checked,
+ })
+ }
+ disabled={
+ !usersWritable ||
+ savingUserId === user.user_id ||
+ !canGrantAdminAccess ||
+ user.super_admin
+ }
+ aria-label={`${user.email} 관리자 페이지 권한`}
+ />
+ {draft.admin_access ? "허용" : "없음"}
+
+ );
+ },
+ },
+ {
+ id: "account_status",
+ accessorFn: (user) => user.account_status,
+ header: "승인 상태",
+ cell: ({ row }) => {
+ const user = row.original;
+ const draft = userDrafts[user.user_id] ?? user;
+ return (
+
+
+ updateDraft(user.user_id, {
+ account_status: event.target
+ .value as AdminManagedUser["account_status"],
+ })
+ }
+ disabled={!usersWritable || savingUserId === user.user_id}
+ aria-label={`${user.email} 승인 상태`}
+ >
+ 승인 대기
+ 승인됨
+ 보류
+
+
+ {accountStatusText(user.account_status)}
+
+
+ );
+ },
+ },
+ {
+ id: "affiliation",
+ accessorFn: (user) => user.affiliation,
+ header: "소속",
+ cell: ({ row }) => {
+ const user = row.original;
+ const draft = userDrafts[user.user_id] ?? user;
+ return (
+
+ updateDraft(user.user_id, { affiliation: event.target.value })
+ }
+ disabled={!usersWritable || savingUserId === user.user_id}
+ aria-label={`${user.email} 소속`}
+ />
+ );
+ },
+ },
+ {
+ id: "cohort_ids",
+ accessorFn: (user) => cohortInputValue(user.cohort_ids),
+ header: "코호트",
+ cell: ({ row }) => {
+ const user = row.original;
+ const draft = userDrafts[user.user_id] ?? user;
+ return (
+
+ updateDraft(user.user_id, {
+ cohort_ids: parseCohorts(event.target.value),
+ })
+ }
+ disabled={!usersWritable || savingUserId === user.user_id}
+ aria-label={`${user.email} 코호트`}
+ />
+ );
+ },
+ },
+ {
+ id: "active_sessions",
+ accessorFn: (user) => user.active_sessions,
+ header: "활성 회기",
+ sortDescFirst: false,
+ cell: ({ row }) => (
+ {row.original.active_sessions}
+ ),
+ },
+ {
+ id: "last_seen_at",
+ accessorFn: (user) => user.last_seen_at ?? 0,
+ header: "최근 접속",
+ cell: ({ row }) => {
+ const user = row.original;
+ const online = isOnline(user.last_seen_at);
+ return (
+
+
+ {online ? "온라인" : "오프라인"}
+
+ {dateTimeLabel(user.last_seen_at)}
+
+ );
+ },
+ },
+ {
+ id: "actions",
+ header: "작업",
+ enableSorting: false,
+ cell: ({ row }) => {
+ const user = row.original;
+ const draft = userDrafts[user.user_id] ?? userDraftFrom(user);
+ const dirty =
+ draft.display_name !== user.display_name ||
+ draft.role !== user.role ||
+ draft.admin_access !== user.admin_access ||
+ draft.account_status !== user.account_status ||
+ draft.affiliation !== user.affiliation ||
+ cohortInputValue(draft.cohort_ids) !==
+ cohortInputValue(user.cohort_ids);
+ return (
+
+ }
+ onClick={() => void deactivateUser(user)}
+ disabled={!usersWritable || deactivatingUserId === user.user_id}
+ >
+ {deactivatingUserId === user.user_id ? "처리 중" : "비활성화"}
+
+ void saveUser(user)}
+ disabled={
+ !usersWritable || !dirty || savingUserId === user.user_id
+ }
+ >
+ {savingUserId === user.user_id ? "저장 중" : "저장"}
+
+
+ );
+ },
+ },
+ ];
+ const userTable = useReactTable({
+ data: filteredUsers,
+ columns: userColumns,
+ state: { sorting: userSorting },
+ onSortingChange: setUserSorting,
+ getCoreRowModel: getCoreRowModel(),
+ getSortedRowModel: getSortedRowModel(),
+ getRowId: (user) => user.user_id,
+ });
+ const visibleUserRows = userTable
+ .getRowModel()
+ .rows.slice(0, MAX_RENDERED_USERS);
const renderAdminDiagnostics = () =>
[...usersDiagnostics, ...ticketsDiagnostics].length > 0 ? (
[
- warning.key,
- `${warning.message} (${warning.detail})`,
- ]),
+ ...[...usersDiagnostics, ...ticketsDiagnostics]
+ .slice(0, 8)
+ .map((warning): [string, string] => [
+ warning.key,
+ `${warning.message} (${warning.detail})`,
+ ]),
...(usersDiagnostics.length + ticketsDiagnostics.length > 8
- ? [["more", `${usersDiagnostics.length + ticketsDiagnostics.length - 8}개 추가 진단 생략`] as [
- string,
- string,
- ]]
+ ? [
+ [
+ "more",
+ `${usersDiagnostics.length + ticketsDiagnostics.length - 8}개 추가 진단 생략`,
+ ] as [string, string],
+ ]
: []),
]}
/>
@@ -1338,7 +1500,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
-
+
{statusMessage(health)}
@@ -1351,7 +1518,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
{updatedAt ? updatedAt.toLocaleTimeString("ko-KR") : ""}
-
+
진행 중 교육
{countLabel(activeSessions)}
@@ -1375,28 +1542,62 @@ export default function Admin({ section = "overview" }: AdminProps) {
-
+
실시간 교육 흐름
사용자 API 기반
-
+
학습자 회기
{countLabel(activeSessions)}
현재 진행 중인 상담 시뮬레이션
-
+
교수자 접속
- {countLabel(users.filter((user) => user.role === "teacher" && isOnline(user.last_seen_at)).length)}
+
+ {countLabel(
+ users.filter(
+ (user) =>
+ user.role === "teacher" && isOnline(user.last_seen_at),
+ ).length,
+ )}
+
리뷰·안전 알림 대응 가능 인원
-
+
관리자 접속
- {countLabel(users.filter((user) => user.role === "admin" && isOnline(user.last_seen_at)).length)}
+
+ {countLabel(
+ users.filter(
+ (user) =>
+ user.role === "admin" && isOnline(user.last_seen_at),
+ ).length,
+ )}
+
운영 대응 가능 인원
-
+
AI 계량 턴
{usage ? countLabel(usage.metered_turns) : "-"}
{usage ? `최근 ${usage.window_days}일` : "계산 중"}
@@ -1404,10 +1605,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
-
+
가용성
- {health ? statusLabel(health.status) : "확인 중"}
+
+ {health ? statusLabel(health.status) : "확인 중"}
+
@@ -1426,7 +1629,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
{uptimeError ? : null}
-
+
AI 비용
{usageSourceLabel(usage)}
@@ -1437,7 +1640,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
{usage ? usageBudgetLabel(usage) : "계산 중"}
{usage ? {usageBudgetDetail(usage)} : null}
- {usage ?
: null}
+ {usage ? (
+
+ ) : null}
{usage ? (
평가 캐시 hit-rate
@@ -1462,32 +1670,47 @@ export default function Admin({ section = "overview" }: AdminProps) {
) : null}
-
+
운영 티켓
- {tickets?.durable ? "DB 큐" : "대기 중"}
+
+ {tickets?.durable ? "DB 큐" : "대기 중"}
+
{ticketsError ?
: null}
{ticketsLoading && !tickets ? (
-
+
) : null}
{!ticketsLoading && tickets && activeTickets.length === 0 ? (
-
+
) : null}
{tickets && activeTickets.length > 0 ? (
-
+
{countLabel(tickets.summary.open_count)}건 미해결
- 높은 우선순위 {countLabel(tickets.summary.high_priority_count)}건 · 24시간 이상 정체{" "}
- {countLabel(tickets.summary.stale_count)}건
+ 높은 우선순위{" "}
+ {countLabel(tickets.summary.high_priority_count)}건 · 24시간
+ 이상 정체 {countLabel(tickets.summary.stale_count)}건
{activeTickets.slice(0, 2).map((ticket) => (
{ticket.subject}
- {ticketStatusLabel(ticket.status)} · {ticketPriorityLabel(ticket.priority)} ·{" "}
+ {ticketStatusLabel(ticket.status)} ·{" "}
+ {ticketPriorityLabel(ticket.priority)} ·{" "}
{ticket.reporter.email}
@@ -1510,15 +1733,20 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
const renderServices = () => (
-
+
{services.map((service) => (
-
+
{service.name}
-
{statusLabel(service.status)}
+
+ {statusLabel(service.status)}
+
{service.detail}
@@ -1540,7 +1768,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
))}
{loading && !health
? Array.from({ length: 3 }).map((_, index) => (
-
+
@@ -1566,7 +1798,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
신규 사용자는 승인 전까지 pending 화면만 볼 수 있습니다.
-
+
{accountCounts.pending}
대기
-
+
{accountCounts.approved}
승인
-
+
{accountCounts.suspended}
보류
@@ -1612,9 +1862,15 @@ export default function Admin({ section = "overview" }: AdminProps) {
) : null}
{usersLoading && !usersData ? (
-
+
{Array.from({ length: 2 }).map((_, index) => (
-
+
@@ -1632,9 +1888,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
) : null}
{pendingUsers.length > 0 ? (
-
+
{pendingUsers.map((user) => (
-
+
{initialOf(user)}
@@ -1646,9 +1905,15 @@ export default function Admin({ section = "overview" }: AdminProps) {
{accountStatusText(user.account_status)}
- {roleText(user.role)}
+
+ {roleText(user.role)}
+
{user.affiliation || "소속 미입력"}
- {user.cohort_ids.length ? cohortInputValue(user.cohort_ids) : "코호트 없음"}
+
+ {user.cohort_ids.length
+ ? cohortInputValue(user.cohort_ids)
+ : "코호트 없음"}
+
접수 {dateTimeLabel(user.created_at)}
@@ -1664,7 +1929,9 @@ export default function Admin({ section = "overview" }: AdminProps) {
size="sm"
variant="secondary"
leading={
}
- onClick={() => void updateUserAccountStatus(user, "suspended")}
+ onClick={() =>
+ void updateUserAccountStatus(user, "suspended")
+ }
disabled={!usersWritable || savingUserId === user.user_id}
>
보류
@@ -1678,13 +1945,14 @@ export default function Admin({ section = "overview" }: AdminProps) {
);
const renderUserCreate = () => (
-
+
새 사용자 등록
{usersWritable ? "DB 저장 가능" : "읽기 전용"}
- 역할과 코호트는 접근 범위를 결정합니다. 허용 도메인 밖 이메일은 정확히 등록된 계정만 로그인 가능합니다.
+ 역할과 코호트는 접근 범위를 결정합니다. 허용 도메인 밖 이메일은 정확히
+ 등록된 계정만 로그인 가능합니다.
{!usersWritable ? (
@@ -1702,7 +1970,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
이메일
setNewUser((current) => ({ ...current, email: event.target.value }))}
+ onChange={(event) =>
+ setNewUser((current) => ({
+ ...current,
+ email: event.target.value,
+ }))
+ }
placeholder="name@example.com"
disabled={!usersWritable || creatingUser}
aria-label="새 사용자 이메일"
@@ -1713,7 +1986,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
- setNewUser((current) => ({ ...current, display_name: event.target.value }))
+ setNewUser((current) => ({
+ ...current,
+ display_name: event.target.value,
+ }))
}
placeholder="홍길동"
disabled={!usersWritable || creatingUser}
@@ -1730,7 +2006,11 @@ export default function Admin({ section = "overview" }: AdminProps) {
role: event.target.value as AdminManagedUser["role"],
}))
}
- disabled={!usersWritable || creatingUser || (newUser.role === "admin" && !canGrantAdminAccess)}
+ disabled={
+ !usersWritable ||
+ creatingUser ||
+ (newUser.role === "admin" && !canGrantAdminAccess)
+ }
aria-label="새 사용자 역할"
>
학습자
@@ -1745,7 +2025,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
type="checkbox"
checked={newUser.admin_access}
onChange={(event) =>
- setNewUser((current) => ({ ...current, admin_access: event.target.checked }))
+ setNewUser((current) => ({
+ ...current,
+ admin_access: event.target.checked,
+ }))
}
disabled={!usersWritable || creatingUser || !canGrantAdminAccess}
aria-label="새 사용자 관리자 페이지 권한"
@@ -1759,7 +2042,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
onChange={(event) =>
setNewUser((current) => ({
...current,
- account_status: event.target.value as AdminManagedUser["account_status"],
+ account_status: event.target
+ .value as AdminManagedUser["account_status"],
}))
}
disabled={!usersWritable || creatingUser}
@@ -1775,7 +2059,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
- setNewUser((current) => ({ ...current, cohort_ids: parseCohorts(event.target.value) }))
+ setNewUser((current) => ({
+ ...current,
+ cohort_ids: parseCohorts(event.target.value),
+ }))
}
placeholder="cohort-a, cohort-b"
disabled={!usersWritable || creatingUser}
@@ -1787,7 +2074,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
- setNewUser((current) => ({ ...current, affiliation: event.target.value }))
+ setNewUser((current) => ({
+ ...current,
+ affiliation: event.target.value,
+ }))
}
placeholder="한신대학교 상담학과"
disabled={!usersWritable || creatingUser}
@@ -1823,190 +2113,118 @@ export default function Admin({ section = "overview" }: AdminProps) {
: "사용자 확인 중"}
-
- {visibleUsers.map((user) => {
- const draft =
- userDrafts[user.user_id] ?? {
- display_name: user.display_name,
- role: user.role,
- admin_access: user.admin_access,
- account_status: user.account_status,
- affiliation: user.affiliation,
- cohort_ids: user.cohort_ids,
- };
- const dirty =
- draft.display_name !== user.display_name ||
- draft.role !== user.role ||
- draft.admin_access !== user.admin_access ||
- draft.account_status !== user.account_status ||
- draft.affiliation !== user.affiliation ||
- cohortInputValue(draft.cohort_ids) !== cohortInputValue(user.cohort_ids);
+
+ {usersData ? (
+
+
+
+ {userTable.getHeaderGroups().map((headerGroup) => (
+
+ {headerGroup.headers.map((header) => {
+ const sorted = header.column.getIsSorted();
+ return (
+
+ {header.column.getCanSort() ? (
+
+
+ {flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )}
+
+
+ {sorted === "asc"
+ ? "↑"
+ : sorted === "desc"
+ ? "↓"
+ : "↕"}
+
+
+ ) : (
+ flexRender(
+ header.column.columnDef.header,
+ header.getContext(),
+ )
+ )}
+
+ );
+ })}
+
+ ))}
+
+
+ {visibleUserRows.map((row) => (
+
+ {row.getVisibleCells().map((cell) => (
+
+ {flexRender(
+ cell.column.columnDef.cell,
+ cell.getContext(),
+ )}
+
+ ))}
+
+ ))}
+
+
+
+ ) : null}
- return (
-
-
-
-
{initialOf(user)}
-
-
{user.display_name}
-
{user.email}
-
-
-
- {roleText(user.role)}
- {user.admin_access ? 관리자 권한 : null}
- {user.super_admin ? 슈퍼 관리자 : null}
-
- {accountStatusText(user.account_status)}
-
-
-
-
-
-
- 이름
-
- updateDraft(user.user_id, { display_name: event.target.value })
- }
- disabled={!usersWritable || savingUserId === user.user_id}
- aria-label={`${user.email} 표시 이름`}
- />
-
-
- 역할
-
- updateDraft(user.user_id, {
- role: event.target.value as AdminManagedUser["role"],
- })
- }
- disabled={
- !usersWritable ||
- savingUserId === user.user_id ||
- (user.role === "admin" && !canGrantAdminAccess)
- }
- aria-label={`${user.email} 역할`}
- >
- 학습자
- 교수자
-
- 관리자
-
-
-
-
-
- updateDraft(user.user_id, { admin_access: event.target.checked })
- }
- disabled={
- !usersWritable ||
- savingUserId === user.user_id ||
- !canGrantAdminAccess ||
- user.super_admin
- }
- aria-label={`${user.email} 관리자 페이지 권한`}
- />
- 관리자 권한
-
-
- 승인 상태
-
- updateDraft(user.user_id, {
- account_status: event.target.value as AdminManagedUser["account_status"],
- })
- }
- disabled={!usersWritable || savingUserId === user.user_id}
- aria-label={`${user.email} 승인 상태`}
- >
- 승인 대기
- 승인됨
- 보류
-
-
-
- 소속
-
- updateDraft(user.user_id, { affiliation: event.target.value })
- }
- disabled={!usersWritable || savingUserId === user.user_id}
- aria-label={`${user.email} 소속`}
- />
-
-
- 코호트
-
- updateDraft(user.user_id, { cohort_ids: parseCohorts(event.target.value) })
- }
- disabled={!usersWritable || savingUserId === user.user_id}
- aria-label={`${user.email} 코호트`}
- />
-
-
-
-
- 활성 세션 {user.active_sessions}
- {accountStatusText(user.account_status)}
- {isOnline(user.last_seen_at) ? "온라인" : "오프라인"}
- 최근 {dateTimeLabel(user.last_seen_at)}
-
-
-
- }
- onClick={() => void deactivateUser(user)}
- disabled={!usersWritable || deactivatingUserId === user.user_id}
- >
- {deactivatingUserId === user.user_id ? "처리 중" : "비활성화"}
-
- void saveUser(user)}
- disabled={!usersWritable || !dirty || savingUserId === user.user_id}
- >
- {savingUserId === user.user_id ? "저장 중" : "저장"}
-
-
-
- );
- })}
-
- {usersLoading && !usersData
- ? Array.from({ length: 2 }).map((_, index) => (
-
+ {usersLoading && !usersData ? (
+
+ {Array.from({ length: 4 }).map((_, index) => (
+
-
- ))
- : null}
+
+ ))}
+
+ ) : null}
{!usersLoading && !usersData ? (
사용자 목록을 불러오지 못했습니다. 새로고침을 눌러 다시 시도하세요.
) : null}
- {!usersLoading && usersData && filteredUsers.length > visibleUsers.length ? (
+ {!usersLoading &&
+ usersData &&
+ filteredUsers.length > visibleUserRows.length ? (
- 검색어를 입력하면 나머지 {filteredUsers.length - visibleUsers.length}명을 더 좁혀 볼 수 있습니다.
+ 검색어를 입력하면 나머지{" "}
+ {filteredUsers.length - visibleUserRows.length}명을 더 좁혀 볼 수
+ 있습니다.
) : null}
{!usersLoading && usersData && filteredUsers.length === 0 ? (
- {hasUserSearch ? "검색 조건에 맞는 사용자가 없습니다." : "아직 등록된 사용자가 없습니다."}
+ {hasUserSearch
+ ? "검색 조건에 맞는 사용자가 없습니다."
+ : "아직 등록된 사용자가 없습니다."}
) : null}
@@ -2015,18 +2233,30 @@ export default function Admin({ section = "overview" }: AdminProps) {
const renderUserActivity = () => (
-
+
역할 분포
{storeLabel(usersData)}
-
-
-
+
+
+
-
+
최근 활동
사용자 last_seen 기준
@@ -2042,13 +2272,17 @@ export default function Admin({ section = "overview" }: AdminProps) {
{user.display_name}
{user.email}
- {roleText(user.role)}
+
+ {roleText(user.role)}
+
{user.active_sessions}회기
{dateTimeLabel(user.last_seen_at)}
))}
{!usersLoading && users.length === 0 ? (
-
활동을 표시할 사용자가 없습니다.
+
+ 활동을 표시할 사용자가 없습니다.
+
) : null}
@@ -2063,7 +2297,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
description="운영자가 누구에게 어떤 작업 권한을 줄지 판단하는 정책 화면입니다."
/>
- 역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책 기준을 먼저 고정합니다.
+ 역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
+ 기준을 먼저 고정합니다.
{ROLE_POLICIES.map((policy) => (
-
+
{policy.role}
- {policy.permissions.length}개 권한
+
+ {policy.permissions.length}개 권한
+
{policy.scope}
@@ -2099,7 +2339,10 @@ export default function Admin({ section = "overview" }: AdminProps) {
{accessTab === "groups" ? (
{GROUP_POLICIES.map((group) => (
-
+
{group.name}
정책 초안
@@ -2116,8 +2359,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
) : null}
{accessTab === "matrix" ? (
-
-
+
+
리소스
관리자
@@ -2155,7 +2402,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
- 사용자가 제출한 실제 DB 큐만 표시합니다. 해결된 항목은 큐에서 내려 최근 이력으로 분리합니다.
+ 사용자가 제출한 실제 DB 큐만 표시합니다. 해결된 항목은 큐에서 내려 최근
+ 이력으로 분리합니다.
@@ -2222,9 +2480,15 @@ export default function Admin({ section = "overview" }: AdminProps) {
key={category}
type="button"
className={ticketCategoryFilter === category ? "is-active" : ""}
- onClick={() => setTicketCategoryFilter(category as TicketCategoryFilter)}
+ onClick={() =>
+ setTicketCategoryFilter(category as TicketCategoryFilter)
+ }
>
-
{ticketCategoryLabel(category as AdminSupportTicket["category"])}
+
+ {ticketCategoryLabel(
+ category as AdminSupportTicket["category"],
+ )}
+
{countLabel(count)}
))}
@@ -2232,124 +2496,133 @@ export default function Admin({ section = "overview" }: AdminProps) {
) : null}
- {ticketsError ? : null}
-
-
- 전체 접수
- {tickets ? countLabel(tickets.summary.total) : "-"}
- {tickets?.durable ? "DB 티켓" : "저장소 확인 중"}
-
-
- 미해결
- {tickets ? countLabel(tickets.summary.open_count) : "-"}
- open, triaged, in_progress
-
-
- 높은 우선순위
- {tickets ? countLabel(tickets.summary.high_priority_count) : "-"}
- 미해결 high, urgent
-
-
- 정체
- {tickets ? countLabel(tickets.summary.stale_count) : "-"}
- 24시간 이상 미변경
-
+ {ticketsError ?
: null}
+
+
+ 전체 접수
+ {tickets ? countLabel(tickets.summary.total) : "-"}
+ {tickets?.durable ? "DB 티켓" : "저장소 확인 중"}
+
+ 미해결
+ {tickets ? countLabel(tickets.summary.open_count) : "-"}
+ open, triaged, in_progress
+
+
+ 높은 우선순위
+
+ {tickets ? countLabel(tickets.summary.high_priority_count) : "-"}
+
+ 미해결 high, urgent
+
+
+ 정체
+ {tickets ? countLabel(tickets.summary.stale_count) : "-"}
+ 24시간 이상 미변경
+
+
- {ticketsLoading && !tickets ? (
-
-
-
- ) : null}
+ {ticketsLoading && !tickets ? (
+
+
+
+ ) : null}
- {!ticketsLoading && tickets && activeTickets.length === 0 ? (
-
-
-
- ) : null}
+ {!ticketsLoading && tickets && activeTickets.length === 0 ? (
+
+
+
+ ) : null}
- {activeTickets.length > 0 ? (
-
- {activeTickets.map((ticket) => {
- const duplicateParentId = ticket.duplicate_parent_candidate_id;
- const canLinkDuplicate =
- ticket.duplicate_count > 0 &&
- Boolean(duplicateParentId) &&
- duplicateParentId !== ticket.ticket_id &&
- duplicateParentId !== ticket.parent_ticket_id;
- return (
-
-
-
-
{ticketCategoryLabel(ticket.category)}
-
{ticket.reporter.email}
-
갱신 {dateTimeLabel(ticket.updated_at)}
+ {activeTickets.length > 0 ? (
+
+ {visibleActiveTickets.map((ticket) => {
+ const duplicateParentId = ticket.duplicate_parent_candidate_id;
+ const canLinkDuplicate =
+ ticket.duplicate_count > 0 &&
+ Boolean(duplicateParentId) &&
+ duplicateParentId !== ticket.ticket_id &&
+ duplicateParentId !== ticket.parent_ticket_id;
+ return (
+
+
+
+ {ticketCategoryLabel(ticket.category)}
+ {ticket.reporter.email}
+ 갱신 {dateTimeLabel(ticket.updated_at)}
+
+
{ticket.subject}
+
{ticket.body}
+ {ticket.duplicate_count > 0 ||
+ ticket.parent_ticket_id ||
+ ticket.child_ticket_count > 0 ? (
+
+ {ticket.parent_ticket_id ? (
+
+ 중복 연결 #{shortTicketId(ticket.parent_ticket_id)}
+
+ ) : ticket.duplicate_count > 0 ? (
+
+ 중복 후보 {countLabel(ticket.duplicate_count)}건
+
+ ) : null}
+ {ticket.child_ticket_count > 0 ? (
+
+ 하위 티켓 {countLabel(ticket.child_ticket_count)}건
+
+ ) : null}
-
{ticket.subject}
-
{ticket.body}
- {(ticket.duplicate_count > 0 || ticket.parent_ticket_id || ticket.child_ticket_count > 0) ? (
-
- {ticket.parent_ticket_id ? (
- 중복 연결 #{shortTicketId(ticket.parent_ticket_id)}
- ) : ticket.duplicate_count > 0 ? (
- 중복 후보 {countLabel(ticket.duplicate_count)}건
- ) : null}
- {ticket.child_ticket_count > 0 ? (
- 하위 티켓 {countLabel(ticket.child_ticket_count)}건
- ) : null}
-
- ) : null}
-
- {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)}
- {ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
- {ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""}
-
-
- {ticketPriorityLabel(ticket.priority)}
- {ticketStatusLabel(ticket.status)}
-
- {canLinkDuplicate ? (
- void updateTicketParent(ticket, duplicateParentId ?? null)}
- disabled={updatingTicketId === ticket.ticket_id}
- >
- 연결
-
- ) : null}
- {ticket.parent_ticket_id ? (
- void updateTicketParent(ticket, null)}
- disabled={updatingTicketId === ticket.ticket_id}
- >
- 해제
-
- ) : null}
- void updateTicketStatus(ticket, "in_progress")}
- disabled={updatingTicketId === ticket.ticket_id || ticket.status === "in_progress"}
- >
- 처리 중
-
- void updateTicketStatus(ticket, "resolved")}
- disabled={updatingTicketId === ticket.ticket_id}
- >
- 해결
-
-
-
- );
- })}
-
- ) : null}
+ ) : null}
+
+ {ticket.source_path || "출처 없음"} · 접수{" "}
+ {dateTimeLabel(ticket.created_at)}
+ {ticket.assigned_group
+ ? ` · 담당 ${ticket.assigned_group}`
+ : ""}
+ {ticket.event_count > 0
+ ? ` · 처리 이력 ${countLabel(ticket.event_count)}건`
+ : ""}
+
+
+
+ {ticketPriorityLabel(ticket.priority)}
+
+
+ {ticketStatusLabel(ticket.status)}
+
+
+
+ );
+ })}
+
+ ) : null}
+ {activeTickets.length > visibleActiveTickets.length ? (
+
+ 우선순위가 높은 {countLabel(visibleActiveTickets.length)}건만 먼저
+ 표시합니다. 나머지 {countLabel(activeTickets.length - visibleActiveTickets.length)}건은
+ 검색이나 상태·카테고리 필터로 좁혀 확인하세요.
+
+ ) : null}
{recentResolvedTickets.length > 0 ? (
@@ -2358,24 +2631,43 @@ export default function Admin({ section = "overview" }: AdminProps) {
최근 해결 이력
{countLabel(recentResolvedTickets.length)}건
-
+
{recentResolvedTickets.map((ticket) => (
-
+
{ticketCategoryLabel(ticket.category)}
{ticket.reporter.email}
- 해결 {dateTimeLabel(ticket.resolved_at ?? ticket.updated_at)}
+
+ 해결{" "}
+ {dateTimeLabel(ticket.resolved_at ?? ticket.updated_at)}
+
{ticket.subject}
{ticket.body}
- {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)}
- {ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""}
- {ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""}
+ {ticket.source_path || "출처 없음"} · 접수{" "}
+ {dateTimeLabel(ticket.created_at)}
+ {ticket.assigned_group
+ ? ` · 담당 ${ticket.assigned_group}`
+ : ""}
+ {ticket.event_count > 0
+ ? ` · 처리 이력 ${countLabel(ticket.event_count)}건`
+ : ""}
- {ticketPriorityLabel(ticket.priority)}
+
+ {ticketPriorityLabel(ticket.priority)}
+
{ticketStatusLabel(ticket.status)}
이력
@@ -2385,7 +2677,6 @@ export default function Admin({ section = "overview" }: AdminProps) {
) : null}
-
>
);
@@ -2416,10 +2707,12 @@ function InlineError({ message }: { message: string }) {
function EmptyState({ title, body }: { title: string; body: string }) {
return (
-
+
);
}
@@ -2435,7 +2728,11 @@ function TabBar({
onChange: (value: T) => void;
}) {
return (
-
+
{items.map(([id, label]) => (
({
);
}
-function RoleMeter({ label, value, total }: { label: string; value: number; total: number }) {
+function RoleMeter({
+ label,
+ value,
+ total,
+}: {
+ label: string;
+ value: number;
+ total: number;
+}) {
const pct = total > 0 ? Math.round((value / total) * 100) : 0;
return (
diff --git a/apps/web/src/pages/AdminAi.tsx b/apps/web/src/pages/AdminAi.tsx
new file mode 100644
index 0000000..7067feb
--- /dev/null
+++ b/apps/web/src/pages/AdminAi.tsx
@@ -0,0 +1,504 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { AppShell } from "../components/shell/AppShell";
+import { Button, Field, Icon, Input, Kicker } from "../components/ui";
+import {
+ adminApi,
+ adminEngineApi,
+ type AdminEngineConfigResponse,
+ type AdminHealthResponse,
+ type AdminUsageResponse,
+} from "../lib/api";
+import "./admin/admin-ai.css";
+
+const WINDOW_OPTIONS = [7, 30, 90] as const;
+const ENGINE_MODES = ["claude_cli", "claude_api", "openai", "solar"] as const;
+
+const ENGINE_MODE_LABEL: Record
= {
+ claude_cli: "Claude CLI 게이트웨이",
+ messages_api: "클라우드 연결",
+ claude_api: "Anthropic API",
+ openai: "OpenAI 호환",
+ solar: "Solar",
+};
+
+function countLabel(value: number): string {
+ return Number.isFinite(value) ? Math.max(0, Math.round(value)).toLocaleString("ko-KR") : "0";
+}
+
+function costLabel(value: number, precise = false): string {
+ if (!Number.isFinite(value) || value <= 0) return "$0";
+ return `$${value.toFixed(precise || value < 0.01 ? 6 : 4)}`;
+}
+
+function rateLabel(value: number): string {
+ if (!Number.isFinite(value) || value <= 0) return "0%";
+ return `${(value * 100).toFixed(1).replace(/\.0$/, "")}%`;
+}
+
+function dateTimeLabel(value?: number | null): string {
+ if (!value || !Number.isFinite(value)) return "기록 없음";
+ return new Intl.DateTimeFormat("ko-KR", {
+ month: "short",
+ day: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ }).format(new Date(value * 1000));
+}
+
+function dayLabel(value: string): string {
+ const date = new Date(`${value}T00:00:00`);
+ if (Number.isNaN(date.getTime())) return value;
+ return new Intl.DateTimeFormat("ko-KR", { month: "numeric", day: "numeric" }).format(date);
+}
+
+function sourceLabel(usage: AdminUsageResponse): string {
+ return usage.durable && usage.source === "database" ? "운영 DB 원장" : "서버 런타임 임시 집계";
+}
+
+function budgetStatusLabel(status: AdminUsageResponse["budget"]["status"]): string {
+ if (status === "exceeded") return "예산 초과";
+ if (status === "warn") return "예산 주의";
+ if (status === "ok") return "예산 정상";
+ return "예산 경고 비활성";
+}
+
+function healthStatusLabel(status?: string): string {
+ if (status === "ok") return "정상";
+ if (status === "down") return "중단";
+ if (status === "degraded") return "제한 운영";
+ return "확인 불가";
+}
+
+export default function AdminAi() {
+ const [windowDays, setWindowDays] = useState<(typeof WINDOW_OPTIONS)[number]>(30);
+ const [usage, setUsage] = useState(null);
+ const [health, setHealth] = useState(null);
+ const [engine, setEngine] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [saved, setSaved] = useState(false);
+ const [error, setError] = useState(null);
+ const initialLoad = useRef(true);
+
+ const load = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const [usageResult, healthResult, engineResult] = await Promise.allSettled([
+ adminApi.usage(windowDays),
+ adminApi.health(),
+ adminEngineApi.get(),
+ ]);
+ if (usageResult.status === "fulfilled") setUsage(usageResult.value);
+ if (healthResult.status === "fulfilled") setHealth(healthResult.value);
+ if (engineResult.status === "fulfilled") setEngine(engineResult.value);
+ const failures = [usageResult, healthResult, engineResult]
+ .filter((result): result is PromiseRejectedResult => result.status === "rejected")
+ .map((result) =>
+ result.reason instanceof Error ? result.reason.message : "일부 운영 정보를 불러오지 못했습니다.",
+ );
+ if (failures.length > 0) setError([...new Set(failures)].join(" · "));
+ } finally {
+ setLoading(false);
+ }
+ }, [windowDays]);
+
+ useEffect(() => {
+ if (initialLoad.current) {
+ initialLoad.current = false;
+ void load();
+ return;
+ }
+ let active = true;
+ setLoading(true);
+ setError(null);
+ void adminApi.usage(windowDays)
+ .then((nextUsage) => {
+ if (active) setUsage(nextUsage);
+ })
+ .catch((cause) => {
+ if (active) {
+ setError(cause instanceof Error ? cause.message : "AI 사용량을 불러오지 못했습니다.");
+ }
+ })
+ .finally(() => {
+ if (active) setLoading(false);
+ });
+ return () => {
+ active = false;
+ };
+ }, [windowDays]);
+
+ const saveEngine = async () => {
+ if (!engine || !engine.engine_url.trim() || !engine.model.trim()) return;
+ setSaving(true);
+ setSaved(false);
+ setError(null);
+ try {
+ const next = await adminEngineApi.update({
+ engine_mode: engine.engine_mode,
+ engine_url: engine.engine_url.trim(),
+ model: engine.model.trim(),
+ });
+ setEngine(next);
+ setSaved(true);
+ setHealth(await adminApi.health().catch(() => health));
+ window.setTimeout(() => setSaved(false), 2400);
+ } catch (cause) {
+ setError(cause instanceof Error ? cause.message : "AI 운영 설정을 저장하지 못했습니다.");
+ } finally {
+ setSaving(false);
+ }
+ };
+
+ const daily = usage?.daily_cost ?? [];
+ const maxDailyCost = Math.max(0, ...daily.map((item) => item.cost_usd));
+ const totalTokens = (usage?.tokens_in ?? 0) + (usage?.tokens_out ?? 0);
+ const coverage = usage && usage.total_turns > 0 ? usage.metered_turns / usage.total_turns : 0;
+ const unmeteredTurns = usage ? Math.max(0, usage.total_turns - usage.metered_turns) : 0;
+ const costPerTurn = usage && usage.metered_turns > 0 ? usage.cost_usd / usage.metered_turns : 0;
+ const costPerThousandTokens = usage && totalTokens > 0 ? (usage.cost_usd / totalTokens) * 1000 : 0;
+ const engineService = health?.services.find((service) => service.key === "engine");
+ const cache = usage?.evaluator_cache;
+ const sortedBreakdown = useMemo(
+ () => [...(usage?.by_provider ?? [])].sort((a, b) => b.cost_usd - a.cost_usd),
+ [usage],
+ );
+
+ return (
+
+
+
+
+ {error ? (
+
+
+ {error}
+
+ ) : null}
+
+
+
+
+ {usage ? sourceLabel(usage) : "계량 원천 확인 중"}
+
+ {usage?.durable
+ ? "호출 완료 시점의 provider·model·token·cost 필드를 DB에서 다시 집계합니다."
+ : "DB 계량이 아니므로 서버 재시작 시 집계가 사라질 수 있습니다."}
+
+
+
+ {WINDOW_OPTIONS.map((days) => (
+ setWindowDays(days)}
+ >
+ {days}일
+
+ ))}
+
+
+
+
+
+ 누적 비용
+ {usage ? costLabel(usage.cost_usd) : "—"}
+ {windowDays}일 DB 집계
+
+
+ 계량 호출
+ {usage ? countLabel(usage.metered_turns) : "—"}
+ 전체 {usage ? countLabel(usage.total_turns) : "—"}턴
+
+
+ 입력 토큰
+ {usage ? countLabel(usage.tokens_in) : "—"}
+ 프롬프트·컨텍스트
+
+
+ 출력 토큰
+ {usage ? countLabel(usage.tokens_out) : "—"}
+ 생성 응답
+
+
+ 호출당 비용
+ {usage ? costLabel(costPerTurn, true) : "—"}
+
+ 1천 토큰당 {usage ? (totalTokens > 0 ? costLabel(costPerThousandTokens, true) : "계산 불가") : "—"}
+
+
+
+
+
+
+
+
+
+ SPEND CONTROL
+
예산과 계량 커버리지
+
+
+ {usage ? budgetStatusLabel(usage.budget.status) : "확인 중"}
+
+
+
+
+
+ 예산 사용
+
+ {usage?.budget.status === "disabled"
+ ? "한도 미설정"
+ : `${costLabel(usage?.cost_usd ?? 0)} / ${costLabel(usage?.budget.limit_usd ?? 0)}`}
+
+
+
+
+
+
+ {usage?.budget.status === "disabled"
+ ? "서버의 ADMIN_USAGE_BUDGET_USD를 설정하면 80% 주의와 100% 초과 상태를 표시합니다."
+ : `잔여 ${costLabel(usage?.budget.remaining_usd ?? 0)} · ${rateLabel(usage?.budget.used_ratio ?? 0)} 사용`}
+
+
+
+
+ DB 계량 커버리지
+ {rateLabel(coverage)}
+
+
+
+
+
+ {unmeteredTurns > 0
+ ? `${countLabel(unmeteredTurns)}개 턴은 provider·model·token·cost 계량 필드가 없습니다.`
+ : "조회 기간의 모든 완료 턴에 계량 정보가 연결되어 있습니다."}
+
+
+
+
+
+
+
+
+ DAILY JOURNAL
+
일별 비용 흐름
+
+
{daily.length}일 기록
+
+ {daily.length > 0 ? (
+
+ {daily.map((item) => {
+ const height = maxDailyCost > 0 ? Math.max(3, (item.cost_usd / maxDailyCost) * 100) : 3;
+ return (
+
+ {costLabel(item.cost_usd)}
+
+
+
+ {dayLabel(item.day)}
+ {countLabel(item.turns)}턴
+
+ );
+ })}
+
+ ) : (
+ 선택한 기간에 비용이 계량된 AI 호출이 없습니다.
+ )}
+
+
+
+
+
+ MODEL LEDGER
+
Provider·모델별 사용 원장
+
+
비용 높은 순
+
+ {sortedBreakdown.length > 0 ? (
+
+
+
+
+ Provider / 모델
+ 호출
+ 입력
+ 출력
+ 비용
+ 호출당
+ 비용 비중
+
+
+
+ {sortedBreakdown.map((row) => (
+
+
+ {row.model}
+ {row.provider}
+
+ {countLabel(row.turns)}
+ {countLabel(row.tokens_in)}
+ {countLabel(row.tokens_out)}
+ {costLabel(row.cost_usd)}
+ {costLabel(row.turns > 0 ? row.cost_usd / row.turns : 0, true)}
+
+
+ 0 ? (row.cost_usd / usage.cost_usd) * 100 : 0}%` }} />
+
+ {rateLabel(usage && usage.cost_usd > 0 ? row.cost_usd / usage.cost_usd : 0)}
+
+
+ ))}
+
+
+
+ ) : (
+ 모델별 계량 행이 아직 없습니다.
+ )}
+
+
+
+
+
+
+
+ RUNTIME ROUTE
+
AI 엔진 설정
+
+
+ {healthStatusLabel(engineService?.status)}
+
+
+ {engine ? (
+ <>
+
+ {ENGINE_MODES.map((mode) => (
+ setEngine({ ...engine, engine_mode: mode })}
+ >
+ {ENGINE_MODE_LABEL[mode]}
+
+ ))}
+
+
+ setEngine({ ...engine, engine_url: event.target.value })}
+ aria-label="AI 연결 주소"
+ />
+
+
+ setEngine({ ...engine, model: event.target.value })}
+ aria-label="AI 기본 모델"
+ />
+
+
+
저장 원천 {engine.durable ? "DB 영구 저장" : "런타임 적용"}
+
현재 소스 {engine.source}
+
최근 변경 {dateTimeLabel(engine.updated_at)}
+
변경자 {engine.updated_by ?? "기록 없음"}
+
+
+ {saved ? <> 저장됨> : engineService?.detail ?? "엔진 상태를 확인합니다."}
+ }
+ onClick={() => void saveEngine()}
+ disabled={saving || !engine.engine_url.trim() || !engine.model.trim()}
+ >
+ {saving ? "저장 중" : "운영 설정 저장"}
+
+
+ >
+ ) : (
+ 현재 AI 엔진 설정을 불러오는 중입니다.
+ )}
+
+
+
+
+
+ EVALUATOR CACHE
+
평가 캐시 효율
+
+
+ {cache?.enabled ? "사용 중" : "비활성"}
+
+
+
+ {cache ? rateLabel(cache.hit_rate) : "—"}
+ hit-rate
+
+
+
요청 {countLabel(cache?.requests ?? 0)}
+
적중 {countLabel(cache?.hits ?? 0)}
+
미스 {countLabel(cache?.misses ?? 0)}
+
저장 {countLabel(cache?.stores ?? 0)}
+
현재 엔트리 {countLabel(cache?.entries ?? 0)}
+
축출 {countLabel(cache?.evictions ?? 0)}
+
+ 캐시는 성공적으로 파싱된 평가 결과만 보관하며 원문 프롬프트와 응답은 저장하지 않습니다.
+
+
+
+
+
+ METERING INTEGRITY
+
DB 계량 상태
+
+
+
+
+
원천
+ {usage ? sourceLabel(usage) : "확인 중"}
+ {usage?.source ?? "—"}
+
+
+
영속성
+ {usage?.durable ? "재시작 후 유지" : "런타임 한정"}
+ {usage?.durable ? "durable=true" : "durable=false"}
+
+
+
미계량 턴
+ {countLabel(unmeteredTurns)}건
+ {unmeteredTurns > 0 ? "감사 필요" : "누락 없음"}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/web/src/pages/AvatarPreview.tsx b/apps/web/src/pages/AvatarPreview.tsx
index d38174d..3739d67 100644
--- a/apps/web/src/pages/AvatarPreview.tsx
+++ b/apps/web/src/pages/AvatarPreview.tsx
@@ -1,5 +1,5 @@
/* =====================================================================
- AvatarPreview — 서연 래스터 아바타 시각 튜닝용 dev 페이지 (인증 없음).
+ AvatarPreview — 서연 SVG 파라미터 아바타 시각 튜닝용 dev 페이지 (인증 없음).
/dev/avatar-preview. 실 서비스 라우트 아님 — 컴포지션·애니메이션 점검 용도.
===================================================================== */
@@ -16,7 +16,6 @@ const SEOYEON: AvatarPersona = {
outfitColor: "#7C8A92",
eyeColor: "#7A5A3C",
accentColor: "#6B5E7D",
- rasterArtSet: "seoyeon-live2d-psd-v2",
realism: 0.4,
expressionBias: "sad",
};
@@ -38,42 +37,29 @@ const PREVIEW_EXPRS: AvatarExpression[] = [
];
export default function AvatarPreview() {
- const rig = typeof window === "undefined" ? "" : new URLSearchParams(window.location.search).get("rig");
- const previewPersona: AvatarPersona = {
- ...SEOYEON,
- rasterArtSet:
- rig === "safe"
- ? "seoyeon"
- : rig === "v3"
- ? "seoyeon-live2d-v3"
- : rig === "psb"
- ? "seoyeon-live2d-psb"
- : SEOYEON.rasterArtSet,
- };
-
return (
{PREVIEW_EXPRS.map((expr) => (
& { expressionBias?: AvatarAffect };
-
-const HISTORY_FILTERS: Array<{
- value: HistoryFilter;
- label: string;
- desc: string;
-}> = [
- { value: "all", label: "전체", desc: "모든 기록" },
- { value: "active", label: "진행 중", desc: "이어갈 회기" },
- { value: "review", label: "리뷰 필요", desc: "AI 피드백" },
- { value: "archived", label: "보관됨", desc: "읽기 전용" },
-];
-
-const PERSONA_AVATAR_LOOKS: Record = {
- P1: {
- skinTone: "#F0DDC4",
- hair: { style: "long-straight", color: "#5A4030" },
- outfitColor: "#7C8A92",
- eyeColor: "#7A5A3C",
- accentColor: "#6B5E7D",
- rasterArtSet: "seoyeon-live2d-psd-v2",
- expressionBias: "sad",
- },
- P4: {
- skinTone: "#E5C0A0",
- hair: { style: "long-straight", color: "#35261F" },
- outfitColor: "#5F6F88",
- eyeColor: "#2F3438",
- accentColor: "#3B6E8F",
- expressionBias: "anxious",
- },
- P5: {
- skinTone: "#D7AD8D",
- hair: { style: "messy", color: "#28231F" },
- outfitColor: "#6F766F",
- eyeColor: "#24292A",
- accentColor: "#7D8784",
- expressionBias: "guarded",
- },
- P6: {
- skinTone: "#E6BE9A",
- hair: { style: "soft-wave", color: "#2D241F" },
- outfitColor: "#776F85",
- eyeColor: "#2E2B35",
- accentColor: "#6B5E7D",
- expressionBias: "conflicted",
- },
- P7: {
- skinTone: "#D5AA88",
- hair: { style: "side-part", color: "#23211E" },
- outfitColor: "#596367",
- eyeColor: "#25292B",
- accentColor: "#6F7777",
- expressionBias: "tired",
- },
-};
-
-function demographicsLine(persona: PersonaSummary | null): string {
- if (!persona) return "내담자 정보를 불러오는 중";
- const parts = [
- demographicText(persona.demographics.age_band),
- demographicText(persona.demographics.grade),
- demographicText(persona.demographics.job),
- demographicText(persona.demographics.status),
- ].filter(Boolean);
- return parts.length ? parts.join(" · ") : "인적 정보 없음";
-}
-
-function theoryLine(persona: PersonaSummary | null): string {
- if (!persona) return "-";
- return persona.theory_target.length ? persona.theory_target.join(" · ") : "공통";
-}
-
-function difficultyLine(persona: PersonaSummary | null): string {
- if (!persona) return "-";
- return DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty;
-}
-
-function personaInitial(persona: PersonaSummary | null): string {
- const name = persona ? shortPersonaName(persona.display_name) : "";
- return (name || persona?.code || "V").slice(0, 1);
-}
-
-function buildDashboardAvatar(persona: PersonaSummary | null, fallbackCode = ""): AvatarPersona {
- if (!persona) {
- return {
- code: fallbackCode || undefined,
- label: "내담자 정보 확인 중",
- ageBand: "adult",
- skinTone: "#D7CCC3",
- hair: { style: "short", color: "#7A7068" },
- outfitColor: "#6F7777",
- eyeColor: "#2B2B2B",
- realism: 0.35,
- };
- }
- const codeKey = persona.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
- const look = PERSONA_AVATAR_LOOKS[codeKey] ?? {};
- const ageBand = personaAgeBand(persona);
- return {
- code: persona.code,
- label: `${shortPersonaName(persona.display_name)} · ${personaMeta(persona)}`,
- ageBand,
- skinTone: look.skinTone ?? "#E3BE9B",
- hair: look.hair ?? {
- style: ageBand === "teen" ? "long-straight" : "bob",
- color: "#31251E",
- },
- outfitColor: look.outfitColor ?? "#6A6874",
- eyeColor: look.eyeColor ?? "#2B2B2B",
- accentColor: look.accentColor,
- rasterArtSet: look.rasterArtSet,
- realism: 0.4,
- resistance: persona.difficulty === "hard" ? 0.68 : persona.difficulty === "moderate" ? 0.45 : 0.3,
- expressionBias: look.expressionBias,
- };
-}
-
-function baselineExpressionFor(persona: PersonaSummary | null): AvatarAffect {
- if (!persona) return "neutral";
- const codeKey = persona.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS;
- return PERSONA_AVATAR_LOOKS[codeKey]?.expressionBias ?? "neutral";
-}
-
-function recapExpressionFor(
- session: LearnerSessionSummary | null,
- persona: PersonaSummary | null,
-): AvatarAffect {
- if (!session) return baselineExpressionFor(persona);
- if (session.review_ready) return "confused";
- if (session.stage === "정리") return "relief";
- if (session.stage === "개입" && persona?.difficulty === "hard") return "conflicted";
- return baselineExpressionFor(persona);
-}
-
-function recapAvatarState(session: LearnerSessionSummary | null): AvatarState {
- if (!session) return "idle";
- return session.status === "active" ? "listening" : "idle";
-}
-
-function truncateText(value: string, max = 118): string {
- const text = value.replace(/\s+/g, " ").trim();
- if (text.length <= max) return text;
- return `${text.slice(0, max - 1)}…`;
-}
-
-function lastTurnText(
- detail: SessionDetailResponse | null,
- speaker: SessionDetailTurn["speaker"],
-): string | null {
- const turns = detail?.turns?.filter((turn) => turn.speaker === speaker) ?? [];
- const last = turns[turns.length - 1];
- return last?.text?.trim() || null;
-}
-
-function recapCoachLine(session: LearnerSessionSummary | null): string {
- if (!session) return "첫 회기에서는 라포 형성과 개방 질문을 먼저 점검합니다.";
- if (session.status === "active") {
- return `${session.stage} 단계에서 멈췄습니다. 이어갈 때는 마지막 내담자 반응을 한 번 반영하고 다음 질문으로 넘어가세요.`;
- }
- if (session.review_ready) {
- return "리뷰가 준비된 회기입니다. 새 연습 전에 상담자 반응과 내담자 상태 변화를 먼저 확인하세요.";
- }
- return "기록만 있는 회기입니다. 같은 내담자로 다시 연습할지, 다른 난도로 확장할지 결정하세요.";
-}
-
-function sessionStatusLabel(session: LearnerSessionSummary): string {
- if (session.archived) return "보관됨";
- if (session.status === "active") return "진행 중";
- if (session.review_ready) return "리뷰 가능";
- return "종료";
-}
-
-function historyBucket(session: LearnerSessionSummary): HistoryFilter {
- if (session.archived) return "archived";
- if (session.status === "active") return "active";
- if (session.review_ready) return "review";
- return "all";
-}
-
-function historyBucketLabel(session: LearnerSessionSummary): string {
- const bucket = historyBucket(session);
- if (bucket === "active") return "이어갈 회기";
- if (bucket === "review") return "AI 피드백 대기";
- return session.archived ? "보관 처리됨" : "종료 기록";
-}
-
-function historyEmptyMessage(filter: HistoryFilter, query: string): string {
- if (query.trim()) return "조건에 맞는 회기가 없습니다.";
- if (filter === "active") return "이어갈 진행 회기가 없습니다.";
- if (filter === "review") return "리뷰가 필요한 회기가 없습니다.";
- if (filter === "archived") return "보관된 종료 회기가 없습니다.";
- return "저장된 기존 회기가 없습니다.";
-}
-
-function sessionDateLabel(value: string): string {
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return "";
- return new Intl.DateTimeFormat("ko-KR", {
- month: "short",
- day: "numeric",
- hour: "2-digit",
- minute: "2-digit",
- }).format(date);
-}
-
-function stageProgressValue(stage: LearnerSessionSummary["stage"] | null | undefined): number {
- if (stage === "라포") return 25;
- if (stage === "탐색") return 50;
- if (stage === "개입") return 75;
- if (stage === "정리") return 100;
- return 0;
-}
-
-function sessionSortTime(session: LearnerSessionSummary): number {
- const value = session.ended_at ?? session.started_at;
- const time = Date.parse(value);
- return Number.isNaN(time) ? 0 : time;
-}
-
-function averageLearnerTurns(sessions: LearnerSessionSummary[]): number | null {
- if (!sessions.length) return null;
- const total = sessions.reduce((sum, session) => sum + session.learner_turn_count, 0);
- return Math.round(total / sessions.length);
-}
-
-function scoreLabel(value: number | null | undefined): string {
- if (value == null) return "평가 부족";
- return `${Math.round(value * 100)}점`;
-}
-
-function rapportLabel(value: number | null | undefined): string {
- if (value == null) return "평가 부족";
- return value >= 0 ? `+${value.toFixed(2)}` : value.toFixed(2);
-}
-
-function trendLabel(value: string | null | undefined): string {
- if (value === "up") return "상승";
- if (value === "down") return "점검 필요";
- if (value === "flat") return "유지";
- return "평가 부족";
-}
-
export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const navigate = useNavigate();
const [personas, setPersonas] = useState([]);
const [sessions, setSessions] = useState([]);
- const [dashboard, setDashboard] = useState(null);
+ const [dashboard, setDashboard] = useState(
+ null,
+ );
const [selectedCode, setSelectedCode] = useState("");
const [loadState, setLoadState] = useState("loading");
- const [sessionLoadState, setSessionLoadState] = useState("loading");
- const [dashboardLoadState, setDashboardLoadState] = useState("loading");
- const [recapDetail, setRecapDetail] = useState(null);
+ const [sessionLoadState, setSessionLoadState] =
+ useState("loading");
+ const [dashboardLoadState, setDashboardLoadState] =
+ useState("loading");
+ const [recapDetail, setRecapDetail] = useState(
+ null,
+ );
const [recapLoadState, setRecapLoadState] = useState("loading");
const [historyFilter, setHistoryFilter] = useState("all");
const [historyQuery, setHistoryQuery] = useState("");
const [archiveBusyId, setArchiveBusyId] = useState(null);
const [archiveNotice, setArchiveNotice] = useState(null);
+ // 중간 폭(≤1180px)에서는 대시보드 섹션을 상단 가로 탭으로 전환한다(2026-07-15 소유자 피드백).
+ // 데스크톱(>1180px)은 2컬럼 그리드 유지 — 탭 바는 CSS로 숨겨지고 이 상태는 무시된다.
+ const [dashTab, setDashTab] = useState<"today" | "records" | "growth">(
+ "today",
+ );
useEffect(() => {
let alive = true;
@@ -298,11 +88,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
setLoadState("loading");
setSessionLoadState("loading");
setDashboardLoadState("loading");
- const [personaResult, sessionResult, dashboardResult] = await Promise.allSettled([
- personaApi.list(),
- sessionApi.list(),
- sessionApi.dashboard(),
- ]);
+ const [personaResult, sessionResult, dashboardResult] =
+ await Promise.allSettled([
+ personaApi.list(),
+ sessionApi.list(),
+ sessionApi.dashboard(),
+ ]);
if (!alive) return;
if (personaResult.status === "fulfilled") {
@@ -310,13 +101,18 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const firstUsableCode = personaRows.find(isUsablePersona)?.code ?? "";
setPersonas(personaRows);
setSelectedCode((cur) =>
- personaRows.some((persona) => persona.code === cur && isUsablePersona(persona))
+ personaRows.some(
+ (persona) => persona.code === cur && isUsablePersona(persona),
+ )
? cur
: firstUsableCode,
);
setLoadState("ready");
} else {
- console.warn("[learner-home] failed to load personas", personaResult.reason);
+ console.warn(
+ "[learner-home] failed to load personas",
+ personaResult.reason,
+ );
setPersonas([]);
setLoadState("error");
}
@@ -325,7 +121,10 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
setSessions(sessionResult.value.sessions ?? []);
setSessionLoadState("ready");
} else {
- console.warn("[learner-home] failed to load sessions", sessionResult.reason);
+ console.warn(
+ "[learner-home] failed to load sessions",
+ sessionResult.reason,
+ );
setSessions([]);
setSessionLoadState("error");
}
@@ -334,7 +133,10 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
setDashboard(dashboardResult.value);
setDashboardLoadState("ready");
} else {
- console.warn("[learner-home] failed to load dashboard", dashboardResult.reason);
+ console.warn(
+ "[learner-home] failed to load dashboard",
+ dashboardResult.reason,
+ );
setDashboard(null);
setDashboardLoadState("error");
}
@@ -344,18 +146,30 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
};
}, []);
- const usablePersonas = useMemo(() => personas.filter(isUsablePersona), [personas]);
+ const usablePersonas = useMemo(
+ () => personas.filter(isUsablePersona),
+ [personas],
+ );
const selected = useMemo(
- () => usablePersonas.find((p) => p.code === selectedCode) ?? usablePersonas[0] ?? null,
+ () =>
+ usablePersonas.find((p) => p.code === selectedCode) ??
+ usablePersonas[0] ??
+ null,
[selectedCode, usablePersonas],
);
const sortedSessions = useMemo(
() => [...sessions].sort((a, b) => sessionSortTime(b) - sessionSortTime(a)),
[sessions],
);
- const recentSessions = useMemo(() => sortedSessions.slice(0, 6), [sortedSessions]);
+ const recentSessions = useMemo(
+ () => sortedSessions.slice(0, 6),
+ [sortedSessions],
+ );
const selectedSessions = useMemo(
- () => sortedSessions.filter((session) => session.persona_code === selected?.code).slice(0, 5),
+ () =>
+ sortedSessions
+ .filter((session) => session.persona_code === selected?.code)
+ .slice(0, 5),
[selected?.code, sortedSessions],
);
const sessionActiveCount = useMemo(
@@ -363,7 +177,13 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
[sessions],
);
const sessionReviewCount = useMemo(
- () => sessions.filter((session) => session.status === "ended" && session.review_ready && !session.archived).length,
+ () =>
+ sessions.filter(
+ (session) =>
+ session.status === "ended" &&
+ session.review_ready &&
+ !session.archived,
+ ).length,
[sessions],
);
const sessionCompletedCount = useMemo(
@@ -395,7 +215,10 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
: sessionLoadState === "error"
? "기록 오류"
: "기록 확인 중";
- const recentAverageTurns = useMemo(() => averageLearnerTurns(recentSessions), [recentSessions]);
+ const recentAverageTurns = useMemo(
+ () => averageLearnerTurns(recentSessions),
+ [recentSessions],
+ );
const historyFilterCounts = useMemo(
() => ({
all: sessions.length,
@@ -403,7 +226,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
review: sessionReviewCount,
archived: sessionArchivedCount,
}),
- [sessionActiveCount, sessionArchivedCount, sessionReviewCount, sessions.length],
+ [
+ sessionActiveCount,
+ sessionArchivedCount,
+ sessionReviewCount,
+ sessions.length,
+ ],
);
const filteredHistorySessions = useMemo(() => {
const query = historyQuery.trim().toLocaleLowerCase("ko-KR");
@@ -429,32 +257,47 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
[sortedSessions],
);
const nextReviewSession = useMemo(
- () => sortedSessions.find((session) => session.review_ready && !session.archived) ?? null,
+ () =>
+ sortedSessions.find(
+ (session) => session.review_ready && !session.archived,
+ ) ?? null,
[sortedSessions],
);
- const spotlightSession = nextActiveSession ?? nextReviewSession ?? recentSessions[0] ?? null;
+ const spotlightSession =
+ nextActiveSession ?? nextReviewSession ?? recentSessions[0] ?? null;
const personaProgressRows = useMemo(() => {
- const dashboardProgress = new Map();
+ const dashboardProgress = new Map<
+ string,
+ LearnerDashboardPersonaProgress
+ >();
for (const row of dashboard?.persona_progress ?? []) {
dashboardProgress.set(row.persona_code, row);
}
const rows = usablePersonas.map((persona) => {
const progress = dashboardProgress.get(persona.code);
- const related = sortedSessions.filter((session) => session.persona_code === persona.code);
+ const related = sortedSessions.filter(
+ (session) => session.persona_code === persona.code,
+ );
const relatedAverageTurns = averageLearnerTurns(related);
return {
code: persona.code,
name: shortPersonaName(persona.display_name),
total: progress?.sessions ?? related.length,
- active: progress?.active_sessions ?? related.filter((session) => session.status === "active").length,
- completed: progress?.completed_sessions ?? related.filter((session) => session.status === "ended").length,
+ active:
+ progress?.active_sessions ??
+ related.filter((session) => session.status === "active").length,
+ completed:
+ progress?.completed_sessions ??
+ related.filter((session) => session.status === "ended").length,
reviewReady:
- progress?.review_ready_sessions ?? related.filter((session) => session.review_ready).length,
+ progress?.review_ready_sessions ??
+ related.filter((session) => session.review_ready).length,
latest: related[0] ?? null,
latestStage: progress?.latest_stage ?? related[0]?.stage ?? null,
latestScore: progress?.latest_score ?? null,
trend: progress?.trend ?? "insufficient",
averageTurns: relatedAverageTurns,
+ rapportPercent: progress?.rapport_percent ?? 0,
};
});
return rows.sort((a, b) => {
@@ -469,7 +312,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
() => Math.max(1, ...personaProgressRows.map((row) => row.total)),
[personaProgressRows],
);
- const topPersona = personaProgressRows.find((row) => row.total > 0) ?? personaProgressRows[0];
+ const topPersona =
+ personaProgressRows.find((row) => row.total > 0) ?? personaProgressRows[0];
const personaCatalogUnavailable = loadState === "ready" && !selected;
const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0;
const rootClassName = [
@@ -512,7 +356,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
}, [spotlightSession, view]);
const startPractice = () => {
- if (selected && isUsablePersona(selected)) navigate(`/learn/session/${selected.code}`);
+ if (selected && isUsablePersona(selected))
+ navigate(`/learn/session/${selected.code}`);
};
const goPrimaryAction = () => {
@@ -536,14 +381,20 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
setSessions(sessionResult.value.sessions ?? []);
setSessionLoadState("ready");
} else {
- console.warn("[learner-home] failed to refresh sessions", sessionResult.reason);
+ console.warn(
+ "[learner-home] failed to refresh sessions",
+ sessionResult.reason,
+ );
setSessionLoadState("error");
}
if (dashboardResult.status === "fulfilled") {
setDashboard(dashboardResult.value);
setDashboardLoadState("ready");
} else {
- console.warn("[learner-home] failed to refresh dashboard", dashboardResult.reason);
+ console.warn(
+ "[learner-home] failed to refresh dashboard",
+ dashboardResult.reason,
+ );
setDashboardLoadState("error");
}
};
@@ -598,6 +449,13 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
action: "리뷰 확인",
};
}
+ if (growth?.trend === "down") {
+ return {
+ title: "최근 평가가 내려가는 흐름입니다.",
+ body: "직전 리뷰의 개선점과 대안 발화를 복습한 뒤, 같은 내담자로 한 회기 더 연습하는 것을 권합니다.",
+ action: "리뷰 다시 보기",
+ };
+ }
if (!sessions.length) {
return {
title: "첫 회기를 시작할 차례입니다.",
@@ -617,30 +475,104 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
body: "최근 회기 흐름이 안정적이면 같은 대상 반복보다 다른 난도나 주호소로 범위를 넓혀야 합니다.",
action: "학습 대상 선택",
};
- }, [nextActiveSession, nextReviewSession, recentAverageTurns, sessionLoadState, sessions.length]);
+ }, [
+ growth?.trend,
+ nextActiveSession,
+ nextReviewSession,
+ recentAverageTurns,
+ sessionLoadState,
+ sessions.length,
+ ]);
+
+ // 보완할 부분(부족한 영역) — 대시보드 평가 데이터에서 결정론으로 파생한다.
+ const weaknessInsights = useMemo(() => {
+ if (!dashboardReady || !growth) return [];
+ const items: { key: string; label: string; detail: string; to: string }[] =
+ [];
+ const watchTotal = (growth.points ?? []).reduce(
+ (sum, point) => sum + (point.watch_count ?? 0),
+ 0,
+ );
+ if (growth.trend === "down") {
+ items.push({
+ key: "trend",
+ label: "평가가 내려가는 흐름",
+ detail:
+ "직전 리뷰의 개선점을 복습한 뒤 같은 내담자로 한 회기 더 연습해 보세요.",
+ to: "/learn/history",
+ });
+ }
+ if (
+ growth.avg_rapport != null &&
+ growth.avg_rapport < 0.35 &&
+ (growth.evaluated_sessions ?? 0) >= 1
+ ) {
+ items.push({
+ key: "rapport",
+ label: "라포 신호가 아직 약함",
+ detail: "감정 반영과 타당화 문장을 먼저 두는 회기를 권합니다.",
+ to: "/learn/practice",
+ });
+ }
+ if (watchTotal > 0) {
+ items.push({
+ key: "watch",
+ label: `살펴볼 개입 ${watchTotal}건`,
+ detail:
+ "리뷰의 '살펴보기' 코멘트를 확인하면 같은 실수를 줄일 수 있습니다.",
+ to: "/learn/history",
+ });
+ }
+ const techniqueCount = (growth.top_techniques ?? []).length;
+ if (
+ techniqueCount > 0 &&
+ techniqueCount < 3 &&
+ (growth.evaluated_sessions ?? 0) >= 2
+ ) {
+ items.push({
+ key: "variety",
+ label: "기법 다양성이 낮음",
+ detail: `최근에는 ${(growth.top_techniques ?? []).join(", ")} 중심입니다. 개방질문·요약 같은 다른 기법도 시도해 보세요.`,
+ to: "/learn/practice",
+ });
+ }
+ return items.slice(0, 3);
+ }, [dashboardReady, growth]);
+ const achievements = dashboard?.achievements ?? [];
const spotlightPersona = useMemo(
() =>
spotlightSession
- ? (usablePersonas.find((persona) => persona.code === spotlightSession.persona_code) ??
- null)
+ ? (usablePersonas.find(
+ (persona) => persona.code === spotlightSession.persona_code,
+ ) ?? null)
: selected,
[selected, spotlightSession, usablePersonas],
);
const effectiveRecapDetail =
- recapDetail && recapDetail.session_id === spotlightSession?.session_id ? recapDetail : null;
+ recapDetail && recapDetail.session_id === spotlightSession?.session_id
+ ? recapDetail
+ : null;
const spotlightAvatar = useMemo(
- () => buildDashboardAvatar(spotlightPersona, spotlightSession?.persona_code),
+ () =>
+ buildDashboardAvatar(spotlightPersona, spotlightSession?.persona_code),
[spotlightPersona, spotlightSession?.persona_code],
);
- const spotlightAffect = recapExpressionFor(spotlightSession, spotlightPersona);
+ const spotlightAffect = recapExpressionFor(
+ spotlightSession,
+ spotlightPersona,
+ );
const spotlightAffectLabel = expressionLabelFor(spotlightAffect);
const lastClientLine = lastTurnText(effectiveRecapDetail, "client");
const lastLearnerLine = lastTurnText(effectiveRecapDetail, "learner");
- const reviewQueue = sortedSessions.filter((session) => session.review_ready).slice(0, 3);
+ const reviewQueue = sortedSessions
+ .filter((session) => session.review_ready)
+ .slice(0, 3);
const recentRecordRows = recentSessions.slice(0, 4);
const recapSessionTitle =
- spotlightPersona?.display_name ?? spotlightSession?.persona_name ?? "첫 연습 대상 선택";
+ spotlightPersona?.display_name ??
+ spotlightSession?.persona_name ??
+ "첫 연습 대상 선택";
const recapPersonaSubtitle = spotlightPersona
? `${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}`
: spotlightSession
@@ -651,7 +583,9 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
? [
"마지막 반응 먼저 반영",
spotlightSession.stage === "라포" ? "안전감 확인" : "감정 변화 확인",
- spotlightSession.stage === "정리" ? "다음 약속 정리" : "탐색 질문 1개 준비",
+ spotlightSession.stage === "정리"
+ ? "다음 약속 정리"
+ : "탐색 질문 1개 준비",
]
: ["낮은 난도부터 시작", "라포 목표 설정", "회기 종료 후 리뷰"];
@@ -659,13 +593,16 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
{
label: "진행 회기",
value: dashboardReady ? `${totalCount}회` : "-",
- meta: dashboardReady ? `${completedCount}회 종료 · ${activeCount}회 진행` : dashboardStatus,
+ meta: dashboardReady
+ ? `${completedCount}회 종료 · ${activeCount}회 진행`
+ : dashboardStatus,
to: "/learn/history",
},
{
label: "리뷰 대기",
value: dashboardReady ? `${reviewCount}건` : "-",
- meta: reviewCount > 0 ? "종료 회기 피드백 확인 필요" : "대기 중인 리뷰 없음",
+ meta:
+ reviewCount > 0 ? "종료 회기 피드백 확인 필요" : "대기 중인 리뷰 없음",
to: "/learn/history",
},
{
@@ -680,7 +617,9 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
{
label: "라포 흐름",
value: dashboardReady ? rapportLabel(growth?.avg_rapport) : "-",
- meta: topPersona?.total ? `${topPersona.name} · ${topPersona.total}회` : "아직 연습 없음",
+ meta: topPersona?.total
+ ? `${topPersona.name} · ${topPersona.total}회`
+ : "아직 연습 없음",
to: "/learn/practice",
},
];
@@ -717,7 +656,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
const bucket = historyBucket(session);
const progress = stageProgressValue(session.stage);
return (
-
+
@@ -731,7 +675,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
{shortPersonaName(session.persona_name)}
- {session.stage} 단계 · {session.turn_count}턴 · {sessionDateLabel(session.started_at)}
+ {session.stage} 단계 · {session.turn_count}턴 ·{" "}
+ {sessionDateLabel(session.started_at)}
@@ -742,7 +687,9 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
navigate(`/learn/session/${session.session_id}`)}
+ onClick={() =>
+ navigate(`/learn/session/${session.session_id}`)
+ }
>
이어하기
@@ -750,15 +697,21 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
navigate(`/learn/session/${session.session_id}/review`)}
+ onClick={() =>
+ navigate(`/learn/session/${session.session_id}/review`)
+ }
>
- {session.review_ready && !session.archived ? "리뷰" : "기록"}
+ {session.review_ready && !session.archived
+ ? "리뷰"
+ : "기록"}
)}
navigate(`/learn/session/${session.persona_code}`)}
+ onClick={() =>
+ navigate(`/learn/session/${session.persona_code}`)
+ }
>
다시 연습
@@ -806,21 +759,30 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
return (
{rows.map((session) => (
-
+
{shortPersonaName(session.persona_name).slice(0, 1)}
{shortPersonaName(session.persona_name)}
- {session.persona_code} · {session.stage} · {sessionDateLabel(session.started_at)}
+ {session.persona_code} · {session.stage} ·{" "}
+ {sessionDateLabel(session.started_at)}
{mode === "review" ? (
navigate(`/learn/session/${session.session_id}/review`)}
+ onClick={() =>
+ navigate(`/learn/session/${session.session_id}/review`)
+ }
>
AI 피드백
@@ -861,7 +823,13 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
{loadState === "loading"
? Array.from({ length: 3 }).map((_, i) => (
-
+
@@ -872,7 +840,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
))
: personas.map((persona) => {
const usablePersona = isUsablePersona(persona);
- const selectedPersona = usablePersona && persona.code === selected?.code;
+ const selectedPersona =
+ usablePersona && persona.code === selected?.code;
const personaMeta =
(DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) +
" · " +
@@ -885,16 +854,23 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
aria-selected={selectedPersona}
aria-disabled={!usablePersona}
disabled={!usablePersona}
- className={`lh-persona ${selectedPersona ? "is-selected" : ""}`}
+ className={surfaceClassName(
+ `lh-persona ${selectedPersona ? "is-selected" : ""}`,
+ { variant: "interactive" },
+ )}
onClick={() => {
if (usablePersona) setSelectedCode(persona.code);
}}
>
{persona.code}
- {persona.display_name}
+
+ {persona.display_name}
+
- {usablePersona ? personaMeta : `${personaMeta} · 사용 불가`}
+ {usablePersona
+ ? personaMeta
+ : `${personaMeta} · 사용 불가`}
{usablePersona
@@ -915,7 +891,14 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
};
return (
-
+
{view === "dashboard" ? (
<>
@@ -924,22 +907,27 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
학습 대시보드
오늘 이어갈 회기를 먼저 봅니다.
- 진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서 정리합니다.
+ 진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서
+ 정리합니다.
- {loadState === "ready" ? `${usablePersonas.length}명 · ${historyStatus}` : "확인 중"}
+ {loadState === "ready"
+ ? `${usablePersonas.length}명 · ${historyStatus}`
+ : "확인 중"}
-
+
{dashboardMetrics.map((metric) => (
navigate(metric.to)}
>
@@ -950,8 +938,49 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
))}
-
-
+ {/* 중간 폭 이하 전용 가로 탭 — 데스크톱에서는 CSS로 숨김 */}
+
+ setDashTab("today")}
+ >
+ 오늘의 회기
+
+ setDashTab("records")}
+ >
+ 기록 · 리뷰
+
+ setDashTab("growth")}
+ >
+ 성장 지표
+
+
+
+
+
오늘의 회기 작업
@@ -968,15 +997,25 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
-
{spotlightSession ? "오늘 이어갈 회기" : "첫 회기 준비"}
+
+ {spotlightSession
+ ? "오늘 이어갈 회기"
+ : "첫 회기 준비"}
+
{recapSessionTitle}
{recapPersonaSubtitle}
- {spotlightSession ? sessionStatusLabel(spotlightSession) : "대기"}
+ {spotlightSession
+ ? sessionStatusLabel(spotlightSession)
+ : "대기"}
@@ -986,8 +1025,10 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
persona={spotlightAvatar}
state={recapAvatarState(spotlightSession)}
affect={spotlightAffect}
- rapport={spotlightSession?.stage === "정리" ? 0.72 : 0.38}
- size={132}
+ rapport={
+ spotlightSession?.stage === "정리" ? 0.72 : 0.38
+ }
+ size={196}
showCaption={false}
showMeta={false}
animated={false}
@@ -996,38 +1037,80 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
- {spotlightSession?.persona_code ?? selected?.code ?? "P-"}
- {spotlightSession ? `${spotlightSession.turn_count}턴` : "선택 전"}
- {spotlightSession ? sessionDateLabel(spotlightSession.started_at) : "새 연습"}
+
+ {spotlightSession?.persona_code ??
+ selected?.code ??
+ "P-"}
+
+
+ {spotlightSession
+ ? `${spotlightSession.turn_count}턴`
+ : "선택 전"}
+
+
+ {spotlightSession
+ ? sessionDateLabel(spotlightSession.started_at)
+ : "새 연습"}
+
-
+
단계
{spotlightSession?.stage ?? "선택 전"}
-
+
표정 힌트
{spotlightAffectLabel}
-
+
최근 흐름
- {spotlightSession ? `${spotlightSession.learner_turn_count}회 응답` : "-"}
+
+ {spotlightSession
+ ? `${spotlightSession.learner_turn_count}회 응답`
+ : "-"}
+
상담 진행
- {spotlightSession ? `${recapProgress}%` : "0%"}
+
+ {spotlightSession ? `${recapProgress}%` : "0%"}
+
-
+
마지막 내담자 반응
{lastClientLine
@@ -1040,13 +1123,22 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
내 마지막 반응
-
{lastLearnerLine ? truncateText(lastLearnerLine, 118) : "아직 기록된 학습자 발화가 없습니다."}
+
+ {lastLearnerLine
+ ? truncateText(lastLearnerLine, 118)
+ : "아직 기록된 학습자 발화가 없습니다."}
+
-
+
AI 코치
{coach.title}
@@ -1055,14 +1147,22 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
{coachHints.map((hint) => (
-
+
{hint}
))}
-
{recapCoachLine(spotlightSession)}
+
+ {recapCoachLine(spotlightSession)}
+
-
+
다음 연습 추천
-
{reviewCount > 0 ? "리뷰 확인을 우선합니다." : "연습 목표를 좁힙니다."}
+
+ {reviewCount > 0
+ ? "리뷰 확인을 우선합니다."
+ : "연습 목표를 좁힙니다."}
+
맞춤 추천
-
-
{reviewCount > 0 ? "AI 피드백 먼저" : "개인 연습"}
+
+
+ {reviewCount > 0 ? "AI 피드백 먼저" : "개인 연습"}
+
{reviewCount > 0
? `${reviewCount}건의 회기 피드백 확인`
@@ -1101,7 +1217,13 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
navigate(reviewCount > 0 ? "/learn/history" : "/learn/practice")}
+ onClick={() =>
+ navigate(
+ reviewCount > 0
+ ? "/learn/history"
+ : "/learn/practice",
+ )
+ }
>
{reviewCount > 0 ? "리뷰 확인하기" : "연습 시작하기"}
@@ -1109,7 +1231,12 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
최근 피드백
@@ -1131,14 +1258,24 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
) : recentFeedback.length > 0 ? (
navigate(`/learn/session/${recentFeedback[0].session_id}/review`)}
+ className={surfaceClassName("lh-feedback-mini__row", {
+ variant: "interactive",
+ flat: true,
+ })}
+ onClick={() =>
+ navigate(
+ `/learn/session/${recentFeedback[0].session_id}/review`,
+ )
+ }
>
- {recentFeedback[0].persona_code} · {recentFeedback[0].stage} ·{" "}
+ {recentFeedback[0].persona_code} ·{" "}
+ {recentFeedback[0].stage} ·{" "}
{scoreLabel(recentFeedback[0].score)}
- {truncateText(recentFeedback[0].note, 86)}
+
+ {truncateText(recentFeedback[0].note, 86)}
+
) : (
평가 피드백은 회기 종료 후 표시됩니다.
@@ -1149,47 +1286,181 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
-
+
최근 기록
방금 끝낸 흐름입니다.
-
navigate("/learn/history")}>
+ navigate("/learn/history")}
+ >
전체 보기
- {renderCompactSessionRows(recentRecordRows, "최근 기록이 없습니다.", "recent")}
+ {renderCompactSessionRows(
+ recentRecordRows,
+ "최근 기록이 없습니다.",
+ "recent",
+ )}
-
+
-
+
리뷰 대기
피드백 받을 기록입니다.
-
navigate("/learn/history")}>
+ navigate("/learn/history")}
+ >
전체 보기
- {renderCompactSessionRows(reviewQueue, "대기 중인 리뷰가 없습니다.", "review")}
+ {renderCompactSessionRows(
+ reviewQueue,
+ "대기 중인 리뷰가 없습니다.",
+ "review",
+ )}
-
-
-
-
반복 대상
-
{topPersona?.total ? `${topPersona.name} · ${topPersona.total}회` : "아직 없음"}
+ {weaknessInsights.length > 0 ? (
+
+
+
+ 보완할 부분
+
평가 기록에서 찾은 신호입니다.
+
+
+ {weaknessInsights.map((item) => (
+
navigate(item.to)}
+ >
+ {item.label}
+ {item.detail}
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+ 내담자와 쌓은 라포
+
회기를 건너 누적됩니다.
+
+
+
+ {personaProgressRows
+ .filter((row) => row.total > 0)
+ .slice(0, 3).length > 0 ? (
+ personaProgressRows
+ .filter((row) => row.total > 0)
+ .slice(0, 3)
+ .map((row) => (
+
+
+ {row.name}
+ {row.total}회
+
+
+
+
+ {row.rapportPercent}%
+
+ ))
+ ) : (
+
+ 첫 회기를 끝내면 라포 누적이 표시됩니다.
+
+ )}
훈련 편향 점검
-
{topPersona?.total ? "주호소 범위를 넓힐 차례" : "첫 회기 이후 표시"}
-
한 대상에 치우치면 다른 주호소 대응력이 늦게 올라옵니다.
+
+ {topPersona?.total
+ ? "주호소 범위를 넓힐 차례"
+ : "첫 회기 이후 표시"}
+
+
+ 한 대상에 치우치면 다른 주호소 대응력이 늦게 올라옵니다.
+
+
+ {achievements.length > 0 ? (
+
+
+
+ 연습 이정표
+
실제 연습 기준으로 열립니다.
+
+
+
+ {achievements.map((item) => (
+
+
+ {item.label}
+
+ ))}
+
+
+ ) : null}
>
@@ -1202,12 +1473,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
학습 대상
연습할 내담자를 선택합니다.
- 대상별 주호소와 난도를 확인한 뒤 새 회기를 시작합니다. 기존 기록은 아래에서 이어가거나 리뷰합니다.
+ 대상별 주호소와 난도를 확인한 뒤 새 회기를 시작합니다. 기존
+ 기록은 아래에서 이어가거나 리뷰합니다.
- {loadState === "ready" ? `${usablePersonas.length}명` : "확인 중"}
+ {loadState === "ready"
+ ? `${usablePersonas.length}명`
+ : "확인 중"}
@@ -1215,26 +1489,36 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
연습 대상
- {loadState === "ready" ? "내담자 목록" : "확인 중"}
+
+ {loadState === "ready" ? "내담자 목록" : "확인 중"}
+
{renderPersonaList()}
-
+
- {personaCatalogUnavailable ? "!" : personaInitial(selected)}
+ {personaCatalogUnavailable
+ ? "!"
+ : personaInitial(selected)}
- {personaCatalogUnavailable ? "catalog" : (selected?.code ?? "...")}
+ {personaCatalogUnavailable
+ ? "catalog"
+ : (selected?.code ?? "...")}
{personaCatalogUnavailable
? "사용 가능한 내담자가 없습니다."
- : (selected?.display_name ?? "내담자 정보를 불러오는 중")}
+ : (selected?.display_name ??
+ "내담자 정보를 불러오는 중")}
{personaCatalogUnavailable
@@ -1246,7 +1530,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
- 회기 종료 후 저장된 대화 기록으로 리뷰와 코칭이 생성됩니다.
+ 회기 종료 후 저장된 대화 기록으로 리뷰와 코칭이
+ 생성됩니다.
-
-
{personaCatalogUnavailable ? "카탈로그 상태" : "주호소"}
+
+
+ {personaCatalogUnavailable ? "카탈로그 상태" : "주호소"}
+
{personaCatalogUnavailable
? "현재 서버 카탈로그가 신뢰 가능한 상태로 확인되지 않아 연습 시작을 막았습니다."
- : (selected?.presenting_summary ?? "내담자 정보를 확인하고 있습니다.")}
+ : (selected?.presenting_summary ??
+ "내담자 정보를 확인하고 있습니다.")}
@@ -1284,30 +1577,60 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
-
+
{historyReady ? activeCount : "–"}
진행 중
-
+
{historyReady ? reviewCount : "–"}
리뷰 가능
-
+
{historyReady ? sessions.length : "–"}
누적 회기
-
+
기존 회기
- {selected ? `${selected.code} · ${selectedSessions.length}회` : "-"}
+
+ {selected
+ ? `${selected.code} · ${selectedSessions.length}회`
+ : "-"}
+
{personaCatalogUnavailable ? (
- 사용 가능한 내담자가 없어 기존 회기를 선택할 수 없습니다.
+
+ 사용 가능한 내담자가 없어 기존 회기를 선택할 수
+ 없습니다.
+
) : (
renderSessionList(selectedSessions)
@@ -1326,7 +1649,8 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
회기 기록
회기 기록을 찾고 정리합니다.
- 이어갈 회기, 피드백 받을 회기, 보관된 회기를 분리해 다음 행동을 바로 선택합니다.
+ 이어갈 회기, 피드백 받을 회기, 보관된 회기를 분리해 다음
+ 행동을 바로 선택합니다.
@@ -1335,11 +1659,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
{HISTORY_FILTERS.map((item) => (
setHistoryFilter(item.value)}
@@ -1352,17 +1682,26 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
기록 목록
{historyFilter === "all"
? "전체 회기"
- : HISTORY_FILTERS.find((item) => item.value === historyFilter)?.label}
+ : HISTORY_FILTERS.find(
+ (item) => item.value === historyFilter,
+ )?.label}
-
{historyReady ? `${filteredHistorySessions.length}개` : historyStatus}
+
+ {historyReady
+ ? `${filteredHistorySessions.length}개`
+ : historyStatus}
+
@@ -1375,12 +1714,17 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
aria-label="기록 검색"
/>
-
+
{HISTORY_FILTERS.map((item) => (
setHistoryFilter(item.value)}
>
@@ -1391,7 +1735,10 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
{archiveNotice ? (
-
+
{archiveNotice}
@@ -1404,7 +1751,11 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
아카이브 기준
읽기 전용
@@ -1425,23 +1776,39 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
-
+
페르소나별 진행
- {historyReady ? `${personaProgressRows.length}명` : "확인 중"}
+
+ {historyReady
+ ? `${personaProgressRows.length}명`
+ : "확인 중"}
+
{sessionLoadState === "loading" ? (
- 회기 기록을 불러오고 있습니다.
+
+ 회기 기록을 불러오고 있습니다.
+
) : personaProgressRows.length > 0 ? (
{personaProgressRows.map((row) => {
- const progressWidth = Math.round((row.total / maxPersonaSessionCount) * 100);
+ const progressWidth = Math.round(
+ (row.total / maxPersonaSessionCount) * 100,
+ );
return (
{
setSelectedCode(row.code);
@@ -1459,7 +1826,10 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
}턴`
: "아직 진행 없음"}
-
+
@@ -1472,7 +1842,9 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
})}
) : (
- 표시할 페르소나 진행 기록이 없습니다.
+
+ 표시할 페르소나 진행 기록이 없습니다.
+
)}
diff --git a/apps/web/src/pages/Login.tsx b/apps/web/src/pages/Login.tsx
index ffc7972..1c6ef7e 100644
--- a/apps/web/src/pages/Login.tsx
+++ b/apps/web/src/pages/Login.tsx
@@ -4,6 +4,7 @@ import { initialPathForUser, useAuth, type Role } from "../lib/auth";
import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api";
import { LoginBrand } from "./login/LoginBrand";
import { LoginPanel, type LoginRoleOption } from "./login/LoginPanel";
+import { AuthShell } from "../components/auth/AuthShell";
import "./login/login.css";
const OAUTH_NOT_CONFIGURED_MESSAGE =
@@ -181,7 +182,7 @@ export default function Login() {
};
return (
-
+
-
+
);
}
diff --git a/apps/web/src/pages/Onboarding.tsx b/apps/web/src/pages/Onboarding.tsx
index be8af26..863c940 100644
--- a/apps/web/src/pages/Onboarding.tsx
+++ b/apps/web/src/pages/Onboarding.tsx
@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button } from "../components/ui";
+import { surfaceClassName } from "../components/ui/Surface";
+import { AuthShell } from "../components/auth/AuthShell";
import { roleHomePath, useAuth } from "../lib/auth";
import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
import "./onboarding.css";
@@ -147,8 +149,8 @@ export default function Onboarding() {
return (
<>
-
-
+
+
Vignette 가입 설정
가입 정보를 입력합니다.
@@ -333,7 +335,7 @@ export default function Onboarding() {
-
+
>
);
}
diff --git a/apps/web/src/pages/PendingApproval.tsx b/apps/web/src/pages/PendingApproval.tsx
index e41fc4f..8db90f0 100644
--- a/apps/web/src/pages/PendingApproval.tsx
+++ b/apps/web/src/pages/PendingApproval.tsx
@@ -1,6 +1,8 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Icon } from "../components/ui";
+import { surfaceClassName } from "../components/ui/Surface";
+import { AuthShell } from "../components/auth/AuthShell";
import { roleHomePath, useAuth } from "../lib/auth";
import "./pending-approval.css";
@@ -34,8 +36,8 @@ export default function PendingApproval() {
return (
<>
-
-
+
+
@@ -73,7 +75,7 @@ export default function PendingApproval() {
-
+
>
);
}
diff --git a/apps/web/src/pages/PersonaStudio.tsx b/apps/web/src/pages/PersonaStudio.tsx
index e2d2d3a..655f7a5 100644
--- a/apps/web/src/pages/PersonaStudio.tsx
+++ b/apps/web/src/pages/PersonaStudio.tsx
@@ -1,24 +1,68 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { AppShell } from "../components/shell/AppShell";
-import { Badge, Button, Card, Dot, Icon, Kicker } from "../components/ui";
+import {
+ Badge,
+ Button,
+ Card,
+ Dot,
+ EmptyState as UiEmptyState,
+ Icon,
+ Kicker,
+ surfaceClassName,
+} from "../components/ui";
import {
ApiError,
personaApi,
personaReviewApi,
+ teacherApi,
type PersonaDraftDetail,
type PersonaDraftGenerateRequest,
- type PersonaDraftPayload,
type PersonaGenerationEvidence,
type PersonaReviewAction,
type PersonaReviewStatus,
type PersonaReviewSummary,
type PersonaSourceDocumentResponse,
type PersonaSummary,
+ type TeacherDashboardResponse,
+ type TeacherGrowthPoint,
} from "../lib/api";
+import {
+ formatDateTimeKo as formatDateTime,
+ formatOptionalPercent,
+} from "../lib/format";
+import {
+ personaDifficultyLabel,
+ personaReviewStatusLabel,
+ personaReviewTone,
+ personaTheoryLabel,
+} from "../lib/personaViewModel";
+import {
+ buildPromptPreviewSections,
+ cloneEmptyDraft,
+ draftDetailToStudioPayload,
+ draftPayloadToStudioPayload,
+ editableListValue,
+ listValue,
+ nextPersonaCode,
+ normalizeCode,
+ numberValue,
+ objectValue,
+ payloadForApi,
+ stringValue,
+ validateDraft,
+ type Difficulty,
+ type PersonaStudioPayload,
+ type PromptPreviewSection,
+ type ValidationResult,
+} from "./persona-studio/model";
import "./persona-studio.css";
type LoadState = "loading" | "ready" | "error";
+type WorkspaceTab = "dashboard" | "catalog" | "personas";
+type PersonaListTab = "all" | "review";
+type PersonaDetailTab = "overview" | "learning" | "settings";
+type AuthoringStep = "source" | "generate" | "edit" | "review";
type StudioTab =
| "overview"
| "clinical"
@@ -27,8 +71,6 @@ type StudioTab =
| "speech"
| "safety"
| "prompt";
-type JsonRecord = Record
;
-type Difficulty = "easy" | "moderate" | "hard";
type NumberSection = "big5" | "resistance" | "affect_baseline";
type SourceKind = PersonaDraftGenerateRequest["source_kind"];
type ObjectSection =
@@ -40,44 +82,6 @@ type ObjectSection =
| "dsm5_dimensional"
| "triggers";
-interface PersonaStudioPayload {
- code: string;
- display_name: string;
- difficulty: Difficulty;
- theory_target: string[];
- demographics: JsonRecord;
- presenting: JsonRecord;
- history: JsonRecord;
- big5: Record;
- resistance: Record;
- speech_style: JsonRecord;
- affect_baseline: Record;
- ccd: JsonRecord;
- dsm5_dimensional: JsonRecord;
- triggers: JsonRecord;
- source_provenance: string;
- is_synthetic: boolean;
- submit_for_review: boolean;
-}
-
-interface ValidationResult {
- errors: string[];
- warnings: string[];
-}
-
-interface PromptPreviewRow {
- label: string;
- value: string;
-}
-
-interface PromptPreviewSection {
- title: string;
- desc?: string;
- rows?: PromptPreviewRow[];
- items?: string[];
- body?: string;
-}
-
const STUDIO_TABS: { key: StudioTab; label: string }[] = [
{ key: "overview", label: "개요" },
{ key: "clinical", label: "임상" },
@@ -89,311 +93,90 @@ const STUDIO_TABS: { key: StudioTab; label: string }[] = [
];
const AUTHORING_STEPS = [
- { label: "자료", desc: "기록·교재·가이드 정리" },
- { label: "생성", desc: "RAG 근거 초안 생성" },
- { label: "편집", desc: "임상·회기·말투 조정" },
- { label: "검수", desc: "근거·위험·버전 확인" },
+ { key: "source", label: "자료", desc: "기록·교재·가이드 정리" },
+ { key: "generate", label: "초안", desc: "근거를 확인하고 초안 생성" },
+ { key: "edit", label: "설정", desc: "임상·회기·말투 조정" },
+ { key: "review", label: "검토", desc: "근거·위험·버전 확인" },
] as const;
-const TAB_GUIDANCE: Record = {
+const WORKSPACE_TABS: { key: WorkspaceTab; label: string; desc: string }[] = [
+ { key: "dashboard", label: "대시보드", desc: "운영·학습·검수 현황" },
+ { key: "catalog", label: "카탈로그", desc: "공개 범위와 구성 정보" },
+ { key: "personas", label: "페르소나", desc: "전체 목록과 검수" },
+];
+
+const AUTHORING_STEP_KEYS = AUTHORING_STEPS.map((step) => step.key);
+const PERSONA_PROGRESS_STORAGE_KEY = "vignette:persona-studio:progress:v1";
+
+const TAB_GUIDANCE: Record<
+ StudioTab,
+ { title: string; desc: string; items: string[] }
+> = {
overview: {
title: "기본 정보는 학습자가 바로 알아볼 수 있는 식별자 중심으로 작성",
desc: "코드, 난이도, 이론, 인구통계는 검색과 배정에 쓰인다. 실제 기록 기반이면 출처를 비워두지 마라.",
- items: ["표시 이름은 교수자가 구분할 수 있는 별칭으로 둔다.", "첫 회기 입장 발화는 시뮬레이션 시작 1분의 톤을 결정한다.", "주호소는 내담자가 직접 말할 수 있는 문장으로 쓴다."],
+ items: [
+ "표시 이름은 교수자가 구분할 수 있는 별칭으로 둔다.",
+ "첫 회기 입장 발화는 시뮬레이션 시작 1분의 톤을 결정한다.",
+ "주호소는 내담자가 직접 말할 수 있는 문장으로 쓴다.",
+ ],
},
clinical: {
title: "임상 배경은 관찰 사실과 내부 가설을 분리해서 작성",
desc: "가족·관계·학교/직장·촉발 사건은 SSOT 자료에서 확인되는 사실을 우선한다. CCD는 내담자가 직접 설명하지 않는 내부 모델이다.",
- items: ["가족/관계에는 현재 기능에 영향을 주는 반복 패턴을 적는다.", "촉발 사건은 날짜보다 상담 장면에서 드러날 행동 변화에 초점을 둔다.", "자동사고는 한 줄에 하나씩, 내담자 머릿속 문장처럼 쓴다."],
+ items: [
+ "가족/관계에는 현재 기능에 영향을 주는 반복 패턴을 적는다.",
+ "촉발 사건은 날짜보다 상담 장면에서 드러날 행동 변화에 초점을 둔다.",
+ "자동사고는 한 줄에 하나씩, 내담자 머릿속 문장처럼 쓴다.",
+ ],
},
resistance: {
title: "수치 파라미터는 대화 난이도를 조절하는 운영값",
desc: "저항값은 임상 진단이 아니라 시뮬레이션 반응 강도다. 과도하게 극단값을 주면 교육용 피드백이 흔들린다.",
- items: ["초기 저항은 첫 3턴의 방어 강도를 의미한다.", "침묵/회피 확률은 상담자 개입이 어긋날 때의 반응 확률이다.", "자살사고 단계는 안전 라우팅과 검증 카드에서 별도로 확인한다."],
+ items: [
+ "초기 저항은 첫 3턴의 방어 강도를 의미한다.",
+ "침묵/회피 확률은 상담자 개입이 어긋날 때의 반응 확률이다.",
+ "자살사고 단계는 안전 라우팅과 검증 카드에서 별도로 확인한다.",
+ ],
},
scenario: {
title: "회기 시나리오는 회차별 장면, 개방 조건, 금지 반응을 함께 둔다",
desc: "교수자는 '이 내담자가 언제, 왜, 어떻게 변하는지'를 보고 싶어 한다. 단순 줄거리보다 상담자 행동에 대한 반응 규칙이 중요하다.",
- items: ["1회기: 입장 태도, 숨기는 정보, 라포가 생기는 조건을 적는다.", "2회기 이후: 이전 회기의 상담자 반응에 따라 드러나는 변화를 적는다.", "표면화 조건에는 어떤 질문/반영이 주호소를 깊게 만드는지 쓴다."],
+ items: [
+ "1회기: 입장 태도, 숨기는 정보, 라포가 생기는 조건을 적는다.",
+ "2회기 이후: 이전 회기의 상담자 반응에 따라 드러나는 변화를 적는다.",
+ "표면화 조건에는 어떤 질문/반영이 주호소를 깊게 만드는지 쓴다.",
+ ],
},
speech: {
title: "말투는 캐릭터 장식이 아니라 상담 기술 평가의 입력값",
desc: "말버릇, 군말, 비언어 단서는 상담자가 반영·명료화·공감하기 어렵거나 쉬운 지점을 만든다.",
- items: ["문장 길이는 짧음/중간/김처럼 운영 가능한 값으로 둔다.", "군말과 말버릇은 과하지 않게 2~4개 정도가 적당하다.", "비언어 단서는 발화 텍스트에 자연스럽게 섞일 행동만 적는다."],
+ items: [
+ "문장 길이는 짧음/중간/김처럼 운영 가능한 값으로 둔다.",
+ "군말과 말버릇은 과하지 않게 2~4개 정도가 적당하다.",
+ "비언어 단서는 발화 텍스트에 자연스럽게 섞일 행동만 적는다.",
+ ],
},
safety: {
title: "역린과 금기어는 모델이 말하지 말아야 할 말이 아니라 반응 조건",
desc: "금기어 목록만 늘리면 시뮬레이션이 경직된다. 어떤 개입에서 어떤 방어·침묵·분노가 생기는지까지 작성해야 한다.",
- items: ["민감 영역은 내담자가 회피하거나 과민 반응하는 주제다.", "상담자 금기는 훈련자가 하면 안 되는 개입 방식이다.", "역린 반응은 침묵, 농담, 반문, 공격성처럼 관찰 가능한 행동으로 쓴다."],
+ items: [
+ "민감 영역은 내담자가 회피하거나 과민 반응하는 주제다.",
+ "상담자 금기는 훈련자가 하면 안 되는 개입 방식이다.",
+ "역린 반응은 침묵, 농담, 반문, 공격성처럼 관찰 가능한 행동으로 쓴다.",
+ ],
},
prompt: {
title: "프롬프트 미리보기는 최종 런타임 계약 검토용",
desc: "여기서는 편집하지 않는다. 누락된 필드가 있으면 앞 탭으로 돌아가 구조화된 데이터에서 수정해라.",
- items: ["내부 설정은 내담자가 직접 설명하지 않도록 유지한다.", "역린과 회기 규칙이 과도하게 모순되지 않는지 확인한다.", "검수 전에는 생성 근거와 안전 항목을 함께 확인한다."],
+ items: [
+ "내부 설정은 내담자가 직접 설명하지 않도록 유지한다.",
+ "역린과 회기 규칙이 과도하게 모순되지 않는지 확인한다.",
+ "검수 전에는 생성 근거와 안전 항목을 함께 확인한다.",
+ ],
},
};
-const EMPTY_STUDIO_DRAFT: PersonaStudioPayload = {
- code: "",
- display_name: "",
- difficulty: "moderate",
- theory_target: ["humanistic"],
- demographics: {
- age_band: "",
- sex: "",
- role: "",
- context: "",
- },
- presenting: {
- complaint: "",
- surface: "",
- first_session_opening: "",
- },
- history: {
- family: "",
- school_or_work: "",
- relationships: "",
- precipitant: "",
- strengths: "",
- },
- big5: {
- O: 0.5,
- C: 0.5,
- E: 0.5,
- A: 0.5,
- N: 0.5,
- },
- resistance: {
- base_resistance: 0.5,
- unlock_rate: 0.1,
- decay_floor: 0.05,
- silence_prob: 0.15,
- deflection_prob: 0.25,
- },
- speech_style: {
- register: "polite",
- avg_sentence_len: "medium",
- fillers: [],
- honorific: true,
- verbal_tics: [],
- nonverbal_cues: [],
- },
- affect_baseline: {
- negative_affect: 0.45,
- hopelessness: 0.2,
- anhedonia: 0.2,
- sleep: 0.2,
- anxiety: 0.35,
- suicide_ideation_stage: 1,
- },
- ccd: {
- core_belief: "",
- intermediate_belief: "",
- automatic_thought: [],
- coping_strategy: "",
- compensatory: "",
- },
- dsm5_dimensional: {
- anxiety: 0.35,
- depression: 0.2,
- somatic: 0.1,
- note: "",
- },
- triggers: {
- sore_spots: [],
- forbidden: [],
- reaction: "",
- taboo_terms: [],
- session_scenarios: [],
- prompt_contract: "",
- },
- source_provenance: "clinical draft",
- is_synthetic: true,
- submit_for_review: false,
-};
-
-function cloneEmptyDraft(): PersonaStudioPayload {
- return JSON.parse(JSON.stringify(EMPTY_STUDIO_DRAFT)) as PersonaStudioPayload;
-}
-
-function isRecord(value: unknown): value is JsonRecord {
- return !!value && typeof value === "object" && !Array.isArray(value);
-}
-
-function objectValue(value: unknown): JsonRecord {
- return isRecord(value) ? value : {};
-}
-
-function stringValue(section: JsonRecord, key: string): string {
- const value = section[key];
- if (typeof value === "string") return value;
- if (typeof value === "number" || typeof value === "boolean") return String(value);
- return "";
-}
-
-function listValue(section: JsonRecord, key: string): string[] {
- const value = section[key];
- if (Array.isArray(value)) {
- return value.map((item) => String(item)).filter((item) => item.trim());
- }
- if (typeof value === "string" && value.trim()) return [value.trim()];
- return [];
-}
-
-function editableListValue(section: JsonRecord, key: string): string[] {
- const value = section[key];
- if (Array.isArray(value)) return value.map((item) => String(item));
- if (typeof value === "string" && value.trim()) return [value.trim()];
- return [];
-}
-
-function numberValue(section: Record, key: string, fallback: number): number {
- const value = section[key];
- return typeof value === "number" && Number.isFinite(value) ? value : fallback;
-}
-
-function normalizeCode(raw: string): string {
- return raw.trim().toUpperCase();
-}
-
-function nextPersonaCode(catalog: PersonaSummary[], reviews: PersonaReviewSummary[]): string {
- const used = new Set();
- for (const persona of catalog) used.add(normalizeCode(persona.code));
- for (const persona of reviews) used.add(normalizeCode(persona.code));
- let next = 1;
- while (used.has(`P${next}`)) next += 1;
- return `P${next}`;
-}
-
-function draftDetailToStudioPayload(detail: PersonaDraftDetail): PersonaStudioPayload {
- const extended = detail as PersonaDraftDetail & { triggers?: JsonRecord };
- return {
- code: detail.code,
- display_name: detail.display_name,
- difficulty:
- detail.difficulty === "easy" || detail.difficulty === "hard" ? detail.difficulty : "moderate",
- theory_target: detail.theory_target,
- demographics: objectValue(detail.demographics),
- presenting: objectValue(detail.presenting),
- history: objectValue(detail.history),
- big5: { ...detail.big5 },
- resistance: { ...detail.resistance },
- speech_style: objectValue(detail.speech_style),
- affect_baseline: { ...detail.affect_baseline },
- ccd: objectValue(detail.ccd),
- dsm5_dimensional: objectValue(detail.dsm5_dimensional),
- triggers: objectValue(extended.triggers),
- source_provenance: detail.source_provenance,
- is_synthetic: detail.is_synthetic,
- submit_for_review: detail.status === "review",
- };
-}
-
-function draftPayloadToStudioPayload(payload: PersonaDraftPayload): PersonaStudioPayload {
- const extended = payload as PersonaDraftPayload & { triggers?: JsonRecord };
- return {
- code: payload.code,
- display_name: payload.display_name,
- difficulty: payload.difficulty,
- theory_target: payload.theory_target ?? ["humanistic"],
- demographics: objectValue(payload.demographics),
- presenting: objectValue(payload.presenting),
- history: objectValue(payload.history),
- big5: { O: 0.5, C: 0.5, E: 0.5, A: 0.5, N: 0.5, ...(payload.big5 ?? {}) },
- resistance: {
- base_resistance: 0.5,
- unlock_rate: 0.1,
- decay_floor: 0.05,
- silence_prob: 0.15,
- deflection_prob: 0.25,
- ...(payload.resistance ?? {}),
- },
- speech_style: objectValue(payload.speech_style),
- affect_baseline: {
- negative_affect: 0.45,
- hopelessness: 0.2,
- anhedonia: 0.2,
- sleep: 0.2,
- anxiety: 0.35,
- suicide_ideation_stage: 1,
- ...(payload.affect_baseline ?? {}),
- },
- ccd: objectValue(payload.ccd),
- dsm5_dimensional: objectValue(payload.dsm5_dimensional),
- triggers: objectValue(extended.triggers),
- source_provenance: payload.source_provenance,
- is_synthetic: payload.is_synthetic,
- submit_for_review: false,
- };
-}
-
-function payloadForApi(
- draft: PersonaStudioPayload,
- submitForReview: boolean,
-): PersonaDraftPayload & { triggers: JsonRecord } {
- const ccd = {
- ...draft.ccd,
- automatic_thought: listValue(draft.ccd, "automatic_thought"),
- };
- const speechStyle = {
- ...draft.speech_style,
- fillers: listValue(draft.speech_style, "fillers"),
- verbal_tics: listValue(draft.speech_style, "verbal_tics"),
- nonverbal_cues: listValue(draft.speech_style, "nonverbal_cues"),
- };
- const triggers = {
- ...draft.triggers,
- sore_spots: listValue(draft.triggers, "sore_spots"),
- forbidden: listValue(draft.triggers, "forbidden"),
- taboo_terms: listValue(draft.triggers, "taboo_terms"),
- session_scenarios: listValue(draft.triggers, "session_scenarios"),
- };
-
- return {
- code: normalizeCode(draft.code),
- display_name: draft.display_name.trim(),
- difficulty: draft.difficulty,
- theory_target: draft.theory_target,
- demographics: draft.demographics,
- presenting: draft.presenting,
- history: draft.history,
- big5: draft.big5,
- resistance: draft.resistance,
- speech_style: speechStyle,
- affect_baseline: draft.affect_baseline,
- ccd,
- dsm5_dimensional: draft.dsm5_dimensional,
- triggers,
- source_provenance: draft.source_provenance.trim(),
- is_synthetic: draft.is_synthetic,
- submit_for_review: submitForReview,
- };
-}
-
-function personaReviewStatusLabel(status: PersonaReviewStatus): string {
- if (status === "review") return "검수 대기";
- if (status === "draft") return "수정 대기";
- if (status === "approved") return "승인됨";
- return "보관됨";
-}
-
-function personaReviewTone(status: PersonaReviewStatus): "accent" | "neutral" | "warn" {
- if (status === "review") return "accent";
- if (status === "draft") return "warn";
- return "neutral";
-}
-
-function formatDateTime(value: string | null | undefined): string {
- if (!value) return "-";
- const date = new Date(value);
- if (Number.isNaN(date.getTime())) return value;
- return date.toLocaleString("ko-KR", {
- month: "2-digit",
- day: "2-digit",
- hour: "2-digit",
- minute: "2-digit",
- });
-}
function completenessLabel(result: ValidationResult): string {
if (result.errors.length === 0 && result.warnings.length === 0) return "완료";
@@ -401,152 +184,29 @@ function completenessLabel(result: ValidationResult): string {
return "미완";
}
-function previewText(value: string): string {
- return value.trim() || "-";
+
+function average(values: number[]): number | null {
+ if (values.length === 0) return null;
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
}
-function previewList(section: JsonRecord, key: string): string[] {
- return listValue(section, key);
+function percentLabel(value: number | null | undefined): string {
+ return formatOptionalPercent(value, "자료 없음");
}
-function previewListText(section: JsonRecord, key: string): string {
- const items = previewList(section, key);
- return items.length ? items.join("\n") : "-";
+function personaOrigin(code: string, source?: string): "system" | "custom" {
+ if (/^P(?:[1-9]|1[01])$/i.test(code)) return "system";
+ return source === "seed" ? "system" : "custom";
}
-function scaleLabel(value: number): string {
- return `${Math.round(value * 100)}%`;
+function reviewStatusDescription(status: PersonaReviewStatus): string {
+ if (status === "review")
+ return "검수자가 승인 또는 수정 요청을 결정해야 합니다.";
+ if (status === "draft") return "작성자가 편집 중인 비공개 초안입니다.";
+ if (status === "approved") return "학습자 카탈로그에 공개된 버전입니다.";
+ return "새 회기 배정에서 제외된 보관 버전입니다.";
}
-function buildPromptPreviewSections(draft: PersonaStudioPayload): PromptPreviewSection[] {
- const demographics = draft.demographics;
- const presenting = draft.presenting;
- const history = draft.history;
- const speech = draft.speech_style;
- const ccd = draft.ccd;
- const dsm5 = draft.dsm5_dimensional;
- const triggers = draft.triggers;
-
- return [
- {
- title: `L1 페르소나 카드: ${previewText(draft.display_name)}`,
- desc: "학습자에게 노출될 식별자와 첫 장면을 검토한다.",
- rows: [
- { label: "코드", value: previewText(draft.code) },
- { label: "난이도", value: draft.difficulty },
- { label: "이론", value: draft.theory_target.length ? draft.theory_target.join(", ") : "-" },
- { label: "출처", value: previewText(draft.source_provenance) },
- { label: "합성 여부", value: draft.is_synthetic ? "합성/작성 초안" : "근거 기반 초안" },
- ],
- },
- {
- title: "인적 범주와 표층 호소",
- rows: [
- { label: "연령대", value: previewText(stringValue(demographics, "age_band")) },
- { label: "성별/역할", value: [stringValue(demographics, "sex"), stringValue(demographics, "role")].filter(Boolean).join(" / ") || "-" },
- { label: "상황", value: previewText(stringValue(demographics, "context")) },
- { label: "주호소", value: previewText(stringValue(presenting, "complaint")) },
- { label: "표면화 방식", value: previewText(stringValue(presenting, "surface")) },
- { label: "첫 회기 입장 발화", value: previewText(stringValue(presenting, "first_session_opening")) },
- ],
- },
- {
- title: "임상 배경과 내부 사례개념화",
- desc: "내담자가 직접 설명하지 않는 내부 모델이다.",
- rows: [
- { label: "가족", value: previewText(stringValue(history, "family")) },
- { label: "학교/직장", value: previewText(stringValue(history, "school_or_work")) },
- { label: "관계", value: previewText(stringValue(history, "relationships")) },
- { label: "촉발 사건", value: previewText(stringValue(history, "precipitant")) },
- { label: "강점", value: previewText(stringValue(history, "strengths")) },
- { label: "핵심 신념", value: previewText(stringValue(ccd, "core_belief")) },
- { label: "중간 신념", value: previewText(stringValue(ccd, "intermediate_belief")) },
- { label: "자동사고", value: previewListText(ccd, "automatic_thought") },
- { label: "대처 전략", value: previewText(stringValue(ccd, "coping_strategy")) },
- { label: "보상 행동", value: previewText(stringValue(ccd, "compensatory")) },
- ],
- },
- {
- title: "말투와 비언어 단서",
- rows: [
- { label: "말투 register", value: previewText(stringValue(speech, "register")) },
- { label: "문장 길이", value: previewText(stringValue(speech, "avg_sentence_len")) },
- { label: "존대", value: stringValue(speech, "honorific") === "false" ? "사용 안 함" : "사용" },
- { label: "군말", value: previewListText(speech, "fillers") },
- { label: "말버릇", value: previewListText(speech, "verbal_tics") },
- { label: "비언어 단서", value: previewListText(speech, "nonverbal_cues") },
- ],
- },
- {
- title: "내부 수치 파라미터",
- desc: "진단이 아니라 시뮬레이션 반응 강도와 평가 입력값이다.",
- rows: [
- { label: "Big5 O/C/E/A/N", value: ["O", "C", "E", "A", "N"].map((key) => `${key} ${scaleLabel(numberValue(draft.big5, key, 0))}`).join(" · ") },
- { label: "저항", value: `기본 ${scaleLabel(numberValue(draft.resistance, "base_resistance", 0))}, 해제 ${scaleLabel(numberValue(draft.resistance, "unlock_rate", 0))}, 바닥 ${scaleLabel(numberValue(draft.resistance, "decay_floor", 0))}` },
- { label: "침묵/회피", value: `침묵 ${scaleLabel(numberValue(draft.resistance, "silence_prob", 0))}, 회피 ${scaleLabel(numberValue(draft.resistance, "deflection_prob", 0))}` },
- { label: "정서 기저선", value: `부정정서 ${scaleLabel(numberValue(draft.affect_baseline, "negative_affect", 0))}, 불안 ${scaleLabel(numberValue(draft.affect_baseline, "anxiety", 0))}, 무망감 ${scaleLabel(numberValue(draft.affect_baseline, "hopelessness", 0))}` },
- { label: "자살사고 단계", value: String(numberValue(draft.affect_baseline, "suicide_ideation_stage", 0)) },
- { label: "DSM-5 차원", value: `불안 ${scaleLabel(numberValue(dsm5 as Record, "anxiety", 0))}, 우울 ${scaleLabel(numberValue(dsm5 as Record, "depression", 0))}, 신체 ${scaleLabel(numberValue(dsm5 as Record, "somatic", 0))}` },
- { label: "DSM 메모", value: previewText(stringValue(dsm5, "note")) },
- ],
- },
- {
- title: "역린과 안전 반응",
- desc: "금지어가 아니라 상담자 개입에 대한 반응 조건으로 검토한다.",
- rows: [
- { label: "민감 영역", value: previewListText(triggers, "sore_spots") },
- { label: "상담자 금기", value: previewListText(triggers, "forbidden") },
- { label: "금기어", value: previewListText(triggers, "taboo_terms") },
- { label: "반응 양상", value: previewText(stringValue(triggers, "reaction")) },
- ],
- },
- {
- title: "회기 시나리오",
- items: previewList(triggers, "session_scenarios"),
- },
- {
- title: "추가 프롬프트 계약",
- body: previewText(stringValue(triggers, "prompt_contract")),
- },
- ];
-}
-
-function validateDraft(
- draft: PersonaStudioPayload,
- submitForReview: boolean,
- codeTaken: boolean,
-): ValidationResult {
- const errors: string[] = [];
- const warnings: string[] = [];
- const code = normalizeCode(draft.code);
-
- if (!/^P\d+$/.test(code)) errors.push("코드는 P숫자 형식이어야 합니다.");
- if (codeTaken) errors.push(`${code} 코드는 이미 공개 또는 검수 큐에서 사용 중입니다.`);
- if (!draft.display_name.trim()) errors.push("표시 이름이 비어 있습니다.");
- if (!draft.source_provenance.trim()) warnings.push("출처/작성 근거가 비어 있습니다.");
-
- if (!stringValue(draft.presenting, "complaint").trim()) {
- if (submitForReview) errors.push("주호소가 비어 있습니다.");
- else warnings.push("주호소가 비어 있습니다.");
- }
- if (!stringValue(draft.history, "precipitant").trim()) warnings.push("촉발 사건이 비어 있습니다.");
- if (!stringValue(draft.ccd, "core_belief").trim()) {
- if (submitForReview) errors.push("CCD 핵심신념이 비어 있습니다.");
- else warnings.push("CCD 핵심신념이 비어 있습니다.");
- }
- if (listValue(draft.ccd, "automatic_thought").length === 0) warnings.push("자동사고가 비어 있습니다.");
- if (listValue(draft.triggers, "sore_spots").length === 0) {
- if (submitForReview) errors.push("역린 민감 영역이 비어 있습니다.");
- else warnings.push("역린 민감 영역이 비어 있습니다.");
- }
- if (listValue(draft.triggers, "forbidden").length === 0) warnings.push("상담자 금기 반응이 비어 있습니다.");
- if (!stringValue(draft.triggers, "reaction").trim()) warnings.push("역린 반응 양상이 비어 있습니다.");
- if (listValue(draft.triggers, "session_scenarios").length === 0) {
- warnings.push("회기별 시나리오가 비어 있습니다.");
- }
-
- return { errors, warnings };
-}
function TextField({
label,
@@ -562,7 +222,11 @@ function TextField({
return (
{label}
- onChange(event.target.value)} placeholder={placeholder} />
+ onChange(event.target.value)}
+ placeholder={placeholder}
+ />
);
}
@@ -654,11 +318,21 @@ function ListField({
);
}
-function PromptPreviewPanel({ sections }: { sections: PromptPreviewSection[] }) {
+function PromptPreviewPanel({
+ sections,
+}: {
+ sections: PromptPreviewSection[];
+}) {
return (
{sections.map((section) => (
-
+
{section.title}
{section.desc ?
{section.desc}
: null}
@@ -666,7 +340,10 @@ function PromptPreviewPanel({ sections }: { sections: PromptPreviewSection[] })
{section.rows?.length ? (
{section.rows.map((row) => (
-
+
{row.label}
{row.value}
@@ -680,7 +357,9 @@ function PromptPreviewPanel({ sections }: { sections: PromptPreviewSection[] })
))}
) : null}
- {section.body ?
{section.body}
: null}
+ {section.body ? (
+
{section.body}
+ ) : null}
{!section.rows?.length && !section.items?.length && !section.body ? (
작성된 항목 없음
) : null}
@@ -721,12 +400,7 @@ function NumberField({
}
function EmptyState({ title, desc }: { title: string; desc: string }) {
- return (
-
- {title}
- {desc}
-
- );
+ return
;
}
function GuidancePanel({ tab }: { tab: StudioTab }) {
@@ -748,47 +422,113 @@ function GuidancePanel({ tab }: { tab: StudioTab }) {
}
export default function PersonaStudio() {
- const [searchParams] = useSearchParams();
+ const [searchParams, setSearchParams] = useSearchParams();
const requestedDraftId = searchParams.get("draft");
+ const requestedView = searchParams.get("view");
+ const workspaceTab: WorkspaceTab =
+ requestedView === "catalog" || requestedView === "personas"
+ ? requestedView
+ : "dashboard";
+ const personaListTab: PersonaListTab =
+ searchParams.get("queue") === "review" ? "review" : "all";
+ const detailKey = searchParams.get("persona");
+ const detailTabParam = searchParams.get("detailTab");
+ const detailTab: PersonaDetailTab =
+ detailTabParam === "learning" || detailTabParam === "settings"
+ ? detailTabParam
+ : "overview";
+ const isAuthoring =
+ searchParams.get("mode") === "create" ||
+ searchParams.get("mode") === "edit";
+ const requestedStep = searchParams.get("step");
+ const authoringStep: AuthoringStep = AUTHORING_STEP_KEYS.includes(
+ requestedStep as AuthoringStep,
+ )
+ ? (requestedStep as AuthoringStep)
+ : "source";
const sourceFileInputRef = useRef
(null);
const [catalog, setCatalog] = useState([]);
const [reviews, setReviews] = useState([]);
+ const [teacherDashboard, setTeacherDashboard] =
+ useState(null);
+ const [dashboardError, setDashboardError] = useState(null);
const [loadState, setLoadState] = useState("loading");
const [loadError, setLoadError] = useState(null);
- const [draft, setDraft] = useState(() => cloneEmptyDraft());
+ const [draft, setDraft] = useState(() =>
+ cloneEmptyDraft(),
+ );
const [sourceText, setSourceText] = useState("");
const [sourceFilename, setSourceFilename] = useState("");
const [sourceKind, setSourceKind] = useState("client_record");
- const [sourceReferences, setSourceReferences] = useState([]);
- const [generationEvidence, setGenerationEvidence] = useState([]);
+ const [sourceReferences, setSourceReferences] = useState<
+ PersonaSourceDocumentResponse[]
+ >([]);
+ const [generationEvidence, setGenerationEvidence] = useState<
+ PersonaGenerationEvidence[]
+ >([]);
const [generationGoal, setGenerationGoal] = useState("");
const [generationSummary, setGenerationSummary] = useState("");
const [generationWarnings, setGenerationWarnings] = useState([]);
const [maskedEntities, setMaskedEntities] = useState([]);
const [editingId, setEditingId] = useState(null);
const [activeTab, setActiveTab] = useState("overview");
- const [busy, setBusy] = useState<"load" | "save" | "submit" | "decide" | "generate" | "archive" | null>(null);
+ const [busy, setBusy] = useState<
+ "load" | "save" | "submit" | "decide" | "generate" | "archive" | null
+ >(null);
const [message, setMessage] = useState(null);
const [formError, setFormError] = useState(null);
- const [autoLoadedDraftId, setAutoLoadedDraftId] = useState