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 ? ( - - ) : ( -
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) { + ) : null} + {ticket.parent_ticket_id ? ( + + ) : null} + + +
+ ); +} + 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 ( +
+ +
+ + 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 ( +
+ + + + {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 ( + + ); + }, + }, + { + id: "account_status", + accessorFn: (user) => user.account_status, + header: "승인 상태", + cell: ({ row }) => { + const user = row.original; + const draft = userDrafts[user.user_id] ?? user; + return ( +
+ + + {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 ? "온라인" : "오프라인"} + + +
+ ); + }, + }, + { + 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 ( +
+ + +
+ ); + }, + }, + ]; + 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) { -
+
-
+
진행 중 교육 {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) => ( -
- + {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) => ( -