전 저장소 리팩터링과 SSOT 정비
2
.gitignore
vendored
|
|
@ -41,6 +41,8 @@ apps/api/uploads/
|
|||
.idea/
|
||||
.vscode/
|
||||
postgres-data/
|
||||
.wrangler/
|
||||
public-runtime-watchdog.failcount
|
||||
|
||||
# 임시 실행/로그 (P1 빌드)
|
||||
*.out
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1236,14 +1278,20 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
""",
|
||||
normalized_external_id,
|
||||
normalized_email,
|
||||
(data.display_name.strip() if data.display_name else 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))
|
||||
_memory_upsert_managed_user(
|
||||
ManagedUserMemoryInput.from_user(user, reactivate=True)
|
||||
)
|
||||
return user
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
|
|
@ -1320,21 +1368,30 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
_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))
|
||||
_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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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,
|
||||
)
|
||||
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,
|
||||
)
|
||||
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
|
||||
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",
|
||||
)
|
||||
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,
|
||||
)
|
||||
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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -211,17 +211,12 @@ 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}")
|
||||
|
||||
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,
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {e}"
|
||||
)
|
||||
|
||||
return _search_response(result)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 평가 근거 — evaluator 정책 래퍼(label_id 동봉, CRAG 게이트)
|
||||
|
|
@ -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}")
|
||||
except RuntimeError as 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,
|
||||
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}"
|
||||
)
|
||||
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
|
|
@ -745,7 +767,9 @@ async def list_my_support_tickets(
|
|||
) 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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
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,
|
||||
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,
|
||||
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")),
|
||||
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,
|
||||
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,
|
||||
context,
|
||||
VoiceTurnInput(
|
||||
learner_text=learner_text,
|
||||
prosody=VoiceProsody(
|
||||
audio_ref=audio_ref,
|
||||
silence_ms=silence_ms,
|
||||
duration_s=duration_s,
|
||||
silence_ms=utterance.prosody.silence_ms,
|
||||
speech_rate=speech_rate,
|
||||
barge_in=barge_in,
|
||||
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"
|
||||
|
||||
|
|
|
|||
126
apps/api/app/runtime_schema.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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:
|
||||
|
|
|
|||
25
apps/api/app/services/evaluation_contract.py
Normal file
|
|
@ -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,
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
||||
|
|
@ -236,10 +238,12 @@ 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 이면 정상)
|
||||
|
||||
|
|
@ -252,7 +256,9 @@ 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_technique: dict[str, int] = Field(
|
||||
default_factory=dict
|
||||
) # technique.value -> count
|
||||
total: int = 0
|
||||
overused: list[str] = Field(default_factory=list) # 과다 사용 군집(category.value)
|
||||
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
|
||||
|
|
@ -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(
|
||||
recent = (
|
||||
"\n".join(
|
||||
f"{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"
|
||||
for t in (ctx.memory.recent_turns or [])[-4:]
|
||||
) or "(직전 맥락 없음)"
|
||||
)
|
||||
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(
|
||||
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 "(없음)"
|
||||
)
|
||||
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(
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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": "그 이름",
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
47
apps/api/app/services/llm_audit.py
Normal file
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
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 (
|
||||
u.role = 'admin'
|
||||
OR u.admin_access
|
||||
OR lower(u.email) = ANY($1::text[])
|
||||
)
|
||||
AND COALESCE((p.notifications->>'account_approval')::boolean, true)
|
||||
ORDER BY u.email
|
||||
""",
|
||||
super_admin_emails,
|
||||
)
|
||||
return [_recipient_from_row(row) for row in rows]
|
||||
return await _approval_recipients(scope="admin")
|
||||
|
||||
|
||||
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 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 <> ''
|
||||
AND lower(u.email) = ANY($1::text[])
|
||||
ACTIVE_NOTIFICATION_RECIPIENTS_SQL
|
||||
+ """
|
||||
AND (
|
||||
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,
|
||||
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:
|
|||
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
|
||||
승인 대기 중인 신규 사용자가 있습니다. 가입 승인 화면에서 수업 또는 연구 참여 범위를 확인한 뒤 승인 상태를 결정해 주세요.
|
||||
</p>
|
||||
{_info_box([
|
||||
{
|
||||
_info_box(
|
||||
[
|
||||
("사용자", display_name),
|
||||
("이메일", email),
|
||||
("요청 역할", role),
|
||||
])}
|
||||
]
|
||||
)
|
||||
}
|
||||
{_button("가입 승인 확인하기", approval_url)}
|
||||
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
|
||||
계정 승인 전까지 해당 사용자는 Vignette 대기 화면만 볼 수 있습니다.
|
||||
|
|
@ -602,12 +619,16 @@ def _render_session_review_ready(payload: dict[str, Any]) -> RenderedEmail:
|
|||
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
|
||||
종료된 학습 회기가 교수자 검토 대기 상태입니다. 회기 리뷰 화면에서 요약과 근거를 확인한 뒤 검토 상태를 남겨 주세요.
|
||||
</p>
|
||||
{_info_box([
|
||||
{
|
||||
_info_box(
|
||||
[
|
||||
("학습자", learner_label),
|
||||
("내담자", persona_name),
|
||||
("회기", f"{session_no}회기" if session_no else "종료 회기"),
|
||||
("종료 시각", ended_at or "기록됨"),
|
||||
])}
|
||||
]
|
||||
)
|
||||
}
|
||||
{_button("회기 검토하기", review_url)}
|
||||
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
|
||||
민감한 회기 내용은 메일에 포함하지 않았습니다. 로그인 후 Vignette에서 확인해 주세요.
|
||||
|
|
@ -641,10 +662,14 @@ def _render_admin_test_email(payload: dict[str, Any]) -> RenderedEmail:
|
|||
<p style="margin:0 0 20px;font-size:15px;line-height:1.7;color:#5a6663;">
|
||||
관리자 메일 알림이 정상적으로 연결되었습니다. 이 메일은 실제 가입 승인이나 회기 검토 요청이 아니라 발송 경로 확인용 테스트입니다.
|
||||
</p>
|
||||
{_info_box([
|
||||
{
|
||||
_info_box(
|
||||
[
|
||||
("요청자", requested_by),
|
||||
("용도", "운영 메일 발송 테스트"),
|
||||
])}
|
||||
]
|
||||
)
|
||||
}
|
||||
{_button("알림 상태 확인하기", notifications_url)}
|
||||
<p style="margin:24px 0 0;font-size:12px;line-height:1.6;color:#93a09c;">
|
||||
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 표현)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
138
apps/api/app/services/tabular_ingest.py
Normal file
|
|
@ -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"]
|
||||
|
|
@ -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, turns_by_id = await _fetch_session_runtime_rows(
|
||||
conn, 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)
|
||||
sessions: list[InProcSession] = []
|
||||
for row in rows:
|
||||
session_id = str(row["id"])
|
||||
|
|
|
|||
|
|
@ -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 []),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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: "초점 이탈/융합",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
"내담자 역할로 응답하겠습니다. 엄마가 가보라고 해서요.",
|
||||
|
|
|
|||
|
|
@ -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,25 +115,50 @@ 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(
|
||||
DatasetManifestInput(
|
||||
export_id="phase3-rl-seed-test",
|
||||
dataset_name="vignette_phase3_recursive_learning_seed",
|
||||
export_status=APPROVED_EXPORT_STATUS,
|
||||
|
|
@ -135,12 +166,21 @@ class DatasetExportTests(unittest.TestCase):
|
|||
records=[],
|
||||
jsonl_path="03-export/anonymized_dataset.jsonl",
|
||||
jsonl_sha256="",
|
||||
pii_findings=[{"kind": "email", "path": "$.text_masked", "sample": "<email:hs.ac.kr>"}],
|
||||
pii_findings=[
|
||||
{
|
||||
"kind": "email",
|
||||
"path": "$.text_masked",
|
||||
"sample": "<email:hs.ac.kr>",
|
||||
}
|
||||
],
|
||||
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,6 +212,7 @@ class DatasetExportTests(unittest.TestCase):
|
|||
write_jsonl([record], path)
|
||||
digest = sha256_file(path)
|
||||
manifest = build_manifest(
|
||||
DatasetManifestInput(
|
||||
export_id="phase3-rl-seed-test",
|
||||
dataset_name="vignette_phase3_recursive_learning_seed",
|
||||
export_status=DRY_RUN_EXPORT_STATUS,
|
||||
|
|
@ -182,6 +223,7 @@ class DatasetExportTests(unittest.TestCase):
|
|||
pii_findings=[],
|
||||
participants_included=1,
|
||||
)
|
||||
)
|
||||
self.assertEqual(manifest["files"][0]["rows"], 1)
|
||||
self.assertEqual(manifest["files"][0]["sha256"], digest)
|
||||
self.assertEqual(manifest["pii_scan"]["status"], "pass")
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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]] = []
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
57
apps/api/app/test_runtime_schema_ssot.py
Normal file
|
|
@ -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()
|
||||
80
apps/api/app/test_session_progress.py
Normal file
|
|
@ -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()
|
||||
240
apps/api/app/test_session_time_and_goals.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
sessions.live_coach,
|
||||
"generate_live_coaching",
|
||||
AsyncMock(return_value=suggestion),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"save_live_coach_event",
|
||||
AsyncMock(side_effect=save_error),
|
||||
) as save_event:
|
||||
) 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(
|
||||
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(
|
||||
),
|
||||
patch.object(
|
||||
sessions,
|
||||
"_build_seed_recall",
|
||||
AsyncMock(return_value=recall),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
fake_create_session,
|
||||
), patch.object(sessions.asyncio, "create_task", close_background):
|
||||
),
|
||||
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(
|
||||
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(
|
||||
),
|
||||
patch.object(sessions.db, "get_pool", return_value=object()),
|
||||
patch.object(
|
||||
sessions.db,
|
||||
"acquire",
|
||||
return_value=FakeAcquire(FakeConn()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
fake_create_session,
|
||||
), patch.object(sessions.asyncio, "create_task", close_background):
|
||||
),
|
||||
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(
|
||||
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]
|
||||
voice_routes.VoiceSessionContext(
|
||||
session_id=sess.session_id,
|
||||
principal=principal,
|
||||
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
|
||||
learner_text="음성 실패 발화",
|
||||
),
|
||||
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,7 +1426,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
websocket = FakeWebSocket()
|
||||
audio = b"\x00\x80" * 1600
|
||||
|
||||
with patch.object(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"transcribe",
|
||||
AsyncMock(
|
||||
|
|
@ -1225,29 +1435,44 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
text="오늘은 좀 힘들었어요.",
|
||||
duration=2.0,
|
||||
provider_events=[
|
||||
{"kind": "voice_activity", "start_ms": 10, "raw_text": "drop"},
|
||||
{
|
||||
"kind": "voice_activity",
|
||||
"start_ms": 10,
|
||||
"raw_text": "drop",
|
||||
},
|
||||
],
|
||||
)
|
||||
),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.orchestrator,
|
||||
"run_turn_generate",
|
||||
successful_turn,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"synthesize_stream",
|
||||
fake_synthesize_stream,
|
||||
),
|
||||
):
|
||||
await voice_routes._handle_utterance(
|
||||
websocket, # type: ignore[arg-type]
|
||||
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"}],
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=sess),
|
||||
) as load_session, patch.object(
|
||||
) as load_session,
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"save_case_worksheet",
|
||||
AsyncMock(return_value=True),
|
||||
) as save_worksheet:
|
||||
) 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, "수정한 주호소")
|
||||
|
||||
|
|
|
|||
99
apps/api/app/test_tabular_ingest.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_practice_access_error",
|
||||
AsyncMock(return_value=None),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.turn_runtime,
|
||||
"load_owned_session",
|
||||
AsyncMock(return_value=(session, None)),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_resolve_session_voice",
|
||||
AsyncMock(return_value=VOICE_PRESET),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"tts_provider",
|
||||
return_value="openai",
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
AsyncMock(return_value=self._bind_result()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_handle_utterance",
|
||||
handle_utterance,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_run_turn_and_speak",
|
||||
AsyncMock(),
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
AsyncMock(return_value=self._bind_result()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
AsyncMock(return_value=self._bind_result()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_handle_utterance",
|
||||
handle_utterance,
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
AsyncMock(return_value=self._bind_result()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
AsyncMock(return_value=self._bind_result()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_handle_utterance",
|
||||
handle_utterance,
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
AsyncMock(return_value=self._bind_result()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_handle_utterance",
|
||||
handle_utterance,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_run_turn_and_speak",
|
||||
run_turn,
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
AsyncMock(return_value=self._bind_result()),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_handle_utterance",
|
||||
handle_utterance,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_run_turn_and_speak",
|
||||
run_turn,
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=None),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
bind_session,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
) as is_available:
|
||||
) 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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_principal_from_websocket",
|
||||
AsyncMock(return_value=_principal(Role.TEACHER)),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_bind_session",
|
||||
bind_session,
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes.voice_service,
|
||||
"is_available",
|
||||
return_value=True,
|
||||
) as is_available:
|
||||
) 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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_load_voice_session",
|
||||
AsyncMock(return_value=(sess, None)),
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"user_onboarding_complete",
|
||||
AsyncMock(return_value=False),
|
||||
), patch.object(
|
||||
),
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"user_has_consent",
|
||||
AsyncMock(return_value=True),
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"user_has_consent",
|
||||
AsyncMock(return_value=False),
|
||||
), patch.object(
|
||||
),
|
||||
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(
|
||||
with (
|
||||
patch.object(
|
||||
voice_routes,
|
||||
"_load_voice_session",
|
||||
AsyncMock(return_value=(sess, None)),
|
||||
), patch.object(
|
||||
),
|
||||
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",
|
||||
|
|
|
|||
|
|
@ -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, ""
|
||||
"""좋은 발화 충전 + 진행 페이싱 충전 게이트.
|
||||
|
||||
기존 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
|
||||
if rapport < _LIVE_COACH_RECHARGE_MIN_RAPPORT:
|
||||
return False, ""
|
||||
|
||||
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 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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 기반으로 동작하고,
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string>();
|
||||
|
|
@ -221,10 +234,12 @@ async function mockAdminSession(
|
|||
|
||||
if (method === "GET" && path.endsWith("/admin/usage")) {
|
||||
seenAdminEndpoints.add("usage");
|
||||
await fulfillJson({
|
||||
await fulfillJson(
|
||||
options.adminUsage ?? {
|
||||
source: "database",
|
||||
durable: true,
|
||||
window_days: 7,
|
||||
generated_at: 1_783_990_800,
|
||||
window_days: Number(url.searchParams.get("window_days") ?? 7),
|
||||
total_turns: 0,
|
||||
metered_turns: 0,
|
||||
tokens_in: 0,
|
||||
|
|
@ -248,6 +263,41 @@ async function mockAdminSession(
|
|||
},
|
||||
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<AdminEngineConfigResponse>;
|
||||
await fulfillJson({
|
||||
...(options.engineConfig ?? {
|
||||
engine_mode: "openai",
|
||||
engine_url: "http://127.0.0.1:9099",
|
||||
model: "gateway-default",
|
||||
}),
|
||||
...body,
|
||||
source: "database",
|
||||
durable: true,
|
||||
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<HTMLElement>(".ad-user,.ad-user-create") ?? button.parentElement;
|
||||
const owner =
|
||||
button.closest<HTMLElement>(".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");
|
||||
}
|
||||
});
|
||||
|
|
|
|||
117
apps/web/e2e/auth-visual.spec.ts
Normal file
|
|
@ -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<HTMLElement>(".lg-brand");
|
||||
const panel = root.querySelector<HTMLElement>(".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,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
@ -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<HTMLElement>(".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<SVGGraphicsElement>('[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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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<HTMLElement>(".vg-nav");
|
||||
const label = document.querySelector<HTMLElement>(".vg-nav__label");
|
||||
const topbar = document.querySelector<HTMLElement>(".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<HTMLElement>(".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<HTMLElement>(".sr-cols--learner");
|
||||
const transcript = document.querySelector<HTMLElement>(".sr-card--transcript");
|
||||
const overview = document.querySelector<HTMLElement>(".sr-overview");
|
||||
const rubric = document.querySelector<HTMLElement>(".sr-card--rubric");
|
||||
const worksheet = document.querySelector<HTMLElement>(".sr-card--worksheet");
|
||||
const prepost = document.querySelector<HTMLElement>(".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 });
|
||||
// 대시보드 탭은 모든 폭에서 상시 노출된다 — 탭별 콘텐츠를 확인 후 기본 탭으로 복귀.
|
||||
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<HTMLElement>(".vg-nav");
|
||||
const main = document.querySelector<HTMLElement>(".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<HTMLElement>(".lh-metric-card");
|
||||
const shellBody = document.querySelector<HTMLElement>(".vg-shell--learner-dashboard .vg-shell__body");
|
||||
const nav = document.querySelector<HTMLElement>(".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<HTMLElement>(".sx-page--prestart .sx-head");
|
||||
const prestart = document.querySelector<HTMLElement>(".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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
// 프리플라이트(목표 선택 포함)는 좁은 뷰포트에서 세로 스크롤을 허용한다 — 데스크톱만 무스크롤 계약.
|
||||
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();
|
||||
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();
|
||||
if ((page.viewportSize()?.width ?? 0) > 1180) {
|
||||
await expectNoDocumentOverflow(page);
|
||||
}
|
||||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
223
apps/web/e2e/public-admin-visual.spec.ts
Normal file
|
|
@ -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<T>(
|
||||
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<AuthMeResponse>(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<HTMLElement>(".ad-root");
|
||||
const main = document.querySelector<HTMLElement>("main");
|
||||
const heading = Array.from(document.querySelectorAll<HTMLElement>("h1,h2,h3")).find(
|
||||
(node) => node.textContent?.includes("현재 서비스 상태"),
|
||||
);
|
||||
const visibleNodes = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(
|
||||
".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<HTMLElement>(".ad-root");
|
||||
const rect = root?.getBoundingClientRect();
|
||||
const visibleNodes = root
|
||||
? Array.from(root.querySelectorAll<HTMLElement>("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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
19
apps/web/knip.json
Normal file
|
|
@ -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"]
|
||||
}
|
||||
1410
apps/web/package-lock.json
generated
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
After Width: | Height: | Size: 213 KiB |
|
After Width: | Height: | Size: 1.7 MiB |
|
After Width: | Height: | Size: 143 KiB |
|
After Width: | Height: | Size: 1.1 MiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 102 KiB |
|
After Width: | Height: | Size: 120 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 104 KiB |
|
After Width: | Height: | Size: 116 KiB |
207
apps/web/scripts/check-design-ssot.mjs
Normal file
|
|
@ -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("<Suspense fallback={<BootScreen />}>")) {
|
||||
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 소유권이 분리되어 있습니다.",
|
||||
);
|
||||
56
apps/web/scripts/run-e2e.mjs
Normal file
|
|
@ -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",
|
||||
]);
|
||||
|
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
minHeight: "100dvh",
|
||||
display: "grid",
|
||||
placeItems: "center",
|
||||
background: "var(--bg-app)",
|
||||
|
|
@ -95,7 +118,11 @@ function OnboardingGate({ children }: { children: ReactNode }) {
|
|||
}
|
||||
return <Navigate to="/onboarding" replace state={{ from: path }} />;
|
||||
}
|
||||
if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) {
|
||||
if (
|
||||
user &&
|
||||
user.onboardingCompletedAt != null &&
|
||||
(path === "/login" || path === "/onboarding")
|
||||
) {
|
||||
return <Navigate to={initialPathForUser(user)} replace />;
|
||||
}
|
||||
return <>{children}</>;
|
||||
|
|
@ -129,27 +156,35 @@ function RootRedirect() {
|
|||
return <Navigate to={user ? initialPathForUser(user) : "/login"} replace />;
|
||||
}
|
||||
|
||||
function ScrollToTopOnPathChange() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
useEffect(() => {
|
||||
function resetDocumentScroll() {
|
||||
window.scrollTo({ top: 0, left: 0, behavior: "auto" });
|
||||
document.documentElement.scrollTop = 0;
|
||||
document.body.scrollTop = 0;
|
||||
}
|
||||
|
||||
function ScrollToTopOnPathChange() {
|
||||
const { pathname } = useLocation();
|
||||
|
||||
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({
|
|||
<main
|
||||
role="alert"
|
||||
style={{
|
||||
minHeight: "100vh",
|
||||
minHeight: "100dvh",
|
||||
padding: 32,
|
||||
background: "var(--bg-app)",
|
||||
color: "var(--text-strong)",
|
||||
|
|
@ -192,8 +227,15 @@ function RouteErrorFallback({
|
|||
<h1 style={{ margin: "6px 0 0", fontSize: "var(--fs-h2)" }}>
|
||||
화면을 표시하지 못했습니다
|
||||
</h1>
|
||||
<p style={{ margin: "6px 0 0", color: "var(--text-body)", lineHeight: 1.55 }}>
|
||||
React 라우트 렌더 중 예외가 발생했습니다. 빈 화면 대신 원인 정보를 표시합니다.
|
||||
<p
|
||||
style={{
|
||||
margin: "6px 0 0",
|
||||
color: "var(--text-body)",
|
||||
lineHeight: 1.55,
|
||||
}}
|
||||
>
|
||||
React 라우트 렌더 중 예외가 발생했습니다. 빈 화면 대신 원인 정보를
|
||||
표시합니다.
|
||||
</p>
|
||||
</div>
|
||||
<dl
|
||||
|
|
@ -209,10 +251,18 @@ function RouteErrorFallback({
|
|||
}}
|
||||
>
|
||||
{[
|
||||
["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]) => (
|
||||
<div key={key} style={{ display: "contents" }}>
|
||||
<dt
|
||||
|
|
@ -270,7 +320,12 @@ class RouteErrorBoundary extends Component<
|
|||
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return <RouteErrorFallback error={this.state.error} errorInfo={this.state.errorInfo} />;
|
||||
return (
|
||||
<RouteErrorFallback
|
||||
error={this.state.error}
|
||||
errorInfo={this.state.errorInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.props.children;
|
||||
}
|
||||
|
|
@ -280,7 +335,9 @@ function AppRoutesWithBoundary() {
|
|||
const { pathname } = useLocation();
|
||||
return (
|
||||
<RouteErrorBoundary resetKey={pathname}>
|
||||
<Suspense fallback={<BootScreen />}>
|
||||
<AppRoutes />
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
);
|
||||
}
|
||||
|
|
@ -406,6 +463,14 @@ function AppRoutes() {
|
|||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/ai"
|
||||
element={
|
||||
<RequireAuth roles={["admin"]}>
|
||||
<AdminAi />
|
||||
</RequireAuth>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/admin/users"
|
||||
element={
|
||||
|
|
|
|||
31
apps/web/src/components/auth/AuthShell.tsx
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type AuthShellVariant = "botanical" | "room";
|
||||
|
||||
export interface AuthShellProps extends HTMLAttributes<HTMLElement> {
|
||||
variant?: AuthShellVariant;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/** 로그인·온보딩·승인 대기가 공유하는 viewport 캔버스. */
|
||||
export function AuthShell({
|
||||
variant = "botanical",
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: AuthShellProps) {
|
||||
return (
|
||||
<main
|
||||
className={[
|
||||
"vg-auth-shell",
|
||||
`vg-auth-shell--${variant}`,
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
85
apps/web/src/components/auth/auth-shell.css
Normal file
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,20 +317,6 @@ export function ClientAvatar({
|
|||
reduced={reduced}
|
||||
/>
|
||||
|
||||
{useRaster ? (
|
||||
<RasterBust
|
||||
artSet={persona.rasterArtSet as string}
|
||||
affect={affect}
|
||||
state={state}
|
||||
breath={breath}
|
||||
blink={blink}
|
||||
mouth={mouthOpen}
|
||||
gazeX={gazeX}
|
||||
gazeY={gazeY}
|
||||
headTilt={params.headTilt}
|
||||
reduced={reduced}
|
||||
/>
|
||||
) : (
|
||||
<svg
|
||||
className="vg-avatar__svg"
|
||||
viewBox="0 0 200 200"
|
||||
|
|
@ -382,7 +366,6 @@ export function ClientAvatar({
|
|||
</g>
|
||||
</g>
|
||||
</svg>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div
|
||||
|
|
@ -626,7 +622,7 @@ export function RasterBust({
|
|||
style={{ opacity: openEyes, zIndex: name.startsWith("lash") ? 12 : 9, transform: layerTransform() }}
|
||||
/>
|
||||
))}
|
||||
{eyeDetailLayers.map(
|
||||
{EYE_DETAIL_LAYERS.map(
|
||||
({ name, mask, zIndex }) => (
|
||||
<img
|
||||
className="vg-raster__layer vg-raster__layer--eyes"
|
||||
|
|
|
|||
|
|
@ -118,12 +118,6 @@ export interface AvatarPersona {
|
|||
eyeColor?: string;
|
||||
/** 캐릭터 고유 보조색 */
|
||||
accentColor?: string;
|
||||
/**
|
||||
* 래스터(비트맵) 아트셋 코드. 지정하면 SVG 파라미터 리그 대신
|
||||
* `/avatar/<artSet>/<variant>.png` 레이어 합성(Live2D식)으로 렌더링한다.
|
||||
* 미지정 시 기존 SVG 리그. asset 은 imagegen(gpt-image-2)+BiRefNet 누끼로 생성.
|
||||
*/
|
||||
rasterArtSet?: string;
|
||||
/** 사실성 0.35~0.45 고정 (불쾌한 골짜기 회피) */
|
||||
realism: number;
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
<div className={"vg-shell" + (hideTopbar ? " vg-shell--fullscreen" : "")}>
|
||||
<div
|
||||
className={[
|
||||
"vg-shell",
|
||||
"vg-shell--botanical",
|
||||
hideTopbar ? "vg-shell--fullscreen" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ")}
|
||||
>
|
||||
{hideTopbar ? null : <Topbar contextLabel={contextLabel} />}
|
||||
<div className={"vg-shell__body" + (showNav ? "" : " vg-shell__body--bare")}>
|
||||
{showNav ? <Sidebar role={shellRole ?? user.role} /> : null}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,7 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
|
|||
],
|
||||
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" },
|
||||
|
|
|
|||
|
|
@ -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<AppTheme>(() => 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) {
|
|||
<button
|
||||
type="button"
|
||||
className="vg-iconbtn"
|
||||
onClick={toggleTheme}
|
||||
onClick={() => setDark(!dark)}
|
||||
aria-label={dark ? "라이트 모드로" : "다크 모드로"}
|
||||
title={dark ? "라이트 모드" : "다크 모드"}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -5,15 +5,22 @@
|
|||
===================================================================== */
|
||||
|
||||
.vg-shell {
|
||||
min-height: 100vh;
|
||||
width: 100%;
|
||||
height: 100vh;
|
||||
height: 100dvh;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
background: var(--bg-app);
|
||||
}
|
||||
.vg-shell--fullscreen {
|
||||
min-height: 100dvh;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* ── 톱바 ── */
|
||||
.vg-topbar {
|
||||
flex: 0 0 var(--topbar-h);
|
||||
min-width: 0;
|
||||
height: var(--topbar-h);
|
||||
background: var(--bg-surface);
|
||||
|
|
@ -104,50 +111,6 @@
|
|||
text-decoration: none;
|
||||
}
|
||||
|
||||
/* 운영 콘솔은 생성 시안처럼 어두운 크롬을 쓴다. 본문 컴포넌트 토큰은 그대로 유지한다. */
|
||||
body[data-role="admin"] .vg-topbar {
|
||||
background: #17211f;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.08);
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__wm,
|
||||
body[data-role="admin"] .vg-topbar__role,
|
||||
body[data-role="admin"] .vg-topbar__uname {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__brand svg {
|
||||
color: #7eb8ad;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__user {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__switch {
|
||||
border-color: rgba(255, 255, 255, 0.1);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__switch-link {
|
||||
color: rgba(238, 244, 242, 0.72);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__switch-link:hover,
|
||||
body[data-role="admin"] .vg-topbar__switch-link.is-active {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__avatar {
|
||||
background: rgba(126, 184, 173, 0.18);
|
||||
color: #bfe0d9;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__role {
|
||||
border-left-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn {
|
||||
color: rgba(238, 244, 242, 0.78);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn:hover {
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 톱바 아이콘 버튼 (테마/로그아웃) */
|
||||
.vg-iconbtn {
|
||||
display: inline-flex;
|
||||
|
|
@ -216,6 +179,9 @@ body[data-role="admin"] .vg-iconbtn:hover {
|
|||
--nav-cur: var(--nav-w);
|
||||
display: grid;
|
||||
grid-template-columns: var(--nav-cur) 1fr;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
align-items: start;
|
||||
/* 전체 높이 사이드바 구분선: sticky 네비(뷰포트 높이)에 의존하지 않고
|
||||
본문 그리드 전체 높이를 따라가는 컨테이너 배경 하어라인. */
|
||||
|
|
@ -231,10 +197,12 @@ body[data-role="admin"] .vg-iconbtn:hover {
|
|||
|
||||
/* ── 좌측 네비 ── */
|
||||
.vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
position: relative;
|
||||
top: auto;
|
||||
align-self: start;
|
||||
height: calc(100vh - var(--topbar-h));
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--bg-surface);
|
||||
/* 구분선은 .vg-shell__body 컨테이너 배경(전체 높이)이 그린다. */
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
|
|
@ -299,86 +267,15 @@ body[data-role="admin"] .vg-iconbtn:hover {
|
|||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
body[data-role="admin"] .vg-shell__body {
|
||||
background-image: linear-gradient(rgba(255, 255, 255, 0.08), rgba(255, 255, 255, 0.08));
|
||||
}
|
||||
body[data-role="admin"] .vg-nav {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(23, 33, 31, 0.98), rgba(26, 38, 42, 0.98)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__label,
|
||||
body[data-role="admin"] .vg-nav__ethic {
|
||||
color: rgba(238, 244, 242, 0.54);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item {
|
||||
color: rgba(238, 244, 242, 0.76);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item .vg-nav__ic {
|
||||
color: rgba(191, 224, 217, 0.72);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active {
|
||||
background: rgba(126, 184, 173, 0.22);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: #91c8bd;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__foot {
|
||||
border-top-color: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
--nav-cur: 108px;
|
||||
background-image: linear-gradient(rgba(38, 88, 83, 0.16), rgba(38, 88, 83, 0.16));
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
background:
|
||||
linear-gradient(180deg, rgba(32, 107, 99, 0.98), rgba(22, 83, 79, 0.98)),
|
||||
var(--asset-warm-elements) center / cover no-repeat;
|
||||
color: #eef4f2;
|
||||
padding: var(--sp-5) 10px;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__label,
|
||||
body[data-role="instructor"] .vg-nav__ethic {
|
||||
color: rgba(238, 244, 242, 0.62);
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 64px;
|
||||
padding: 9px 8px;
|
||||
color: rgba(238, 244, 242, 0.82);
|
||||
font-size: 12px;
|
||||
text-align: center;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
|
||||
color: rgba(218, 242, 238, 0.78);
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item.is-active {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav__foot {
|
||||
border-top-color: rgba(255, 255, 255, 0.13);
|
||||
}
|
||||
|
||||
/* ── 메인 콘텐츠 ── */
|
||||
.vg-main {
|
||||
min-width: 0; /* 그리드 자식 overflow 방지 */
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
scrollbar-gutter: stable;
|
||||
padding: var(--sp-7) var(--sp-6) var(--sp-8);
|
||||
}
|
||||
.vg-main__inner {
|
||||
|
|
@ -402,9 +299,6 @@ body[data-role="instructor"] .vg-nav__foot {
|
|||
/* 그리드·구분선이 함께 축소 폭을 따른다 */
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
|
@ -427,15 +321,17 @@ body[data-role="instructor"] .vg-nav__foot {
|
|||
scroll-padding-bottom: var(--sp-6);
|
||||
}
|
||||
.vg-shell__body {
|
||||
display: block;
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 58px minmax(0, 1fr);
|
||||
background-image: none; /* 네비가 상단 가로바로 전환 — 세로 구분선 제거 */
|
||||
}
|
||||
.vg-shell__body--bare {
|
||||
display: block;
|
||||
}
|
||||
.vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
position: relative;
|
||||
top: auto;
|
||||
z-index: 29;
|
||||
height: 58px;
|
||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
||||
|
|
@ -490,186 +386,196 @@ body[data-role="instructor"] .vg-nav__foot {
|
|||
.vg-main {
|
||||
padding: var(--sp-5) var(--sp-4) var(--sp-7);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar {
|
||||
background: #1d7169;
|
||||
border-bottom-color: rgba(255, 255, 255, 0.12);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__wm,
|
||||
body[data-role="instructor"] .vg-topbar__role,
|
||||
body[data-role="instructor"] .vg-topbar__uname,
|
||||
body[data-role="instructor"] .vg-iconbtn {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__wm .v,
|
||||
body[data-role="instructor"] .vg-topbar__mark {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__brand svg {
|
||||
color: #eef4f2;
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__role {
|
||||
border-left-color: rgba(255, 255, 255, 0.18);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__user {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
body[data-role="instructor"] .vg-topbar__avatar {
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
color: #ffffff;
|
||||
}
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.vg-main--bleed {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Unified role chrome
|
||||
역할별 제품처럼 보이던 톱바/사이드바 변형을 하나의 앱 크롬으로 통합한다.
|
||||
역할 차이는 tokens.css의 accent만 사용하고, 형태·밀도·배경 체계는 동일하게 유지한다. */
|
||||
body[data-role="admin"] .vg-topbar,
|
||||
body[data-role="instructor"] .vg-topbar {
|
||||
background: var(--bg-surface);
|
||||
border-bottom-color: var(--hair);
|
||||
/* ── Botanical glass canvas ── */
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav,
|
||||
body[data-role] .vg-shell--botanical .vg-main {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
.vg-shell--botanical .vg-main:not(.vg-main--bleed),
|
||||
.vg-shell--learner-dashboard .vg-main:not(.vg-main--bleed) {
|
||||
background: transparent;
|
||||
}
|
||||
/* 학습 대시보드 전용 규칙은 콘텐츠 패딩만 소유한다. 앱 크롬은 아래 SSOT를 그대로 쓴다. */
|
||||
.vg-shell--learner-dashboard .vg-main:not(.vg-main--bleed) {
|
||||
padding: 36px 32px 72px;
|
||||
background: transparent;
|
||||
}
|
||||
/* ── App chrome authority (파일 최종 우선순위) ── */
|
||||
.vg-shell--botanical {
|
||||
--topbar-h: 64px;
|
||||
--nav-w: 230px;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-topbar {
|
||||
height: var(--topbar-h);
|
||||
background: color-mix(in srgb, var(--bg-surface) 76%, transparent);
|
||||
border-bottom-color: var(--botanical-hair);
|
||||
color: var(--text-strong);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
[data-theme="dark"] body[data-role] .vg-shell--botanical .vg-topbar {
|
||||
background: color-mix(in srgb, var(--bg-app) 76%, transparent);
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__wm,
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__role,
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__uname,
|
||||
body[data-role] .vg-shell--botanical .vg-iconbtn {
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__wm,
|
||||
body[data-role="admin"] .vg-topbar__role,
|
||||
body[data-role="admin"] .vg-topbar__uname,
|
||||
body[data-role="admin"] .vg-iconbtn,
|
||||
body[data-role="instructor"] .vg-topbar__wm,
|
||||
body[data-role="instructor"] .vg-topbar__role,
|
||||
body[data-role="instructor"] .vg-topbar__uname,
|
||||
body[data-role="instructor"] .vg-iconbtn {
|
||||
color: var(--text-strong);
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__wm {
|
||||
font-size: 19px;
|
||||
font-weight: 760;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__wm .v,
|
||||
body[data-role="instructor"] .vg-topbar__wm .v,
|
||||
body[data-role="admin"] .vg-topbar__mark,
|
||||
body[data-role="instructor"] .vg-topbar__mark,
|
||||
body[data-role="admin"] .vg-topbar__brand svg,
|
||||
body[data-role="instructor"] .vg-topbar__brand svg {
|
||||
color: var(--accent);
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__role {
|
||||
border-left-color: var(--botanical-hair);
|
||||
font-size: 14px;
|
||||
letter-spacing: 0;
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__role,
|
||||
body[data-role="instructor"] .vg-topbar__role {
|
||||
border-left-color: var(--hair);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__user,
|
||||
body[data-role="instructor"] .vg-topbar__user {
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__user {
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
body[data-role="admin"] .vg-topbar__avatar,
|
||||
body[data-role="instructor"] .vg-topbar__avatar {
|
||||
body[data-role] .vg-shell--botanical .vg-topbar__avatar {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
body[data-role="admin"] .vg-iconbtn:hover,
|
||||
body[data-role="instructor"] .vg-iconbtn:hover {
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-role="admin"] .vg-shell__body,
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
--nav-cur: var(--nav-w);
|
||||
background-image: linear-gradient(var(--hair), var(--hair));
|
||||
background-color: var(--glass-canvas);
|
||||
background-image:
|
||||
linear-gradient(var(--botanical-hair), var(--botanical-hair)),
|
||||
radial-gradient(circle at 68% 18%, color-mix(in srgb, var(--accent) 12%, transparent), transparent 34rem),
|
||||
radial-gradient(circle at 42% 72%, color-mix(in srgb, var(--botanical-gold) 13%, transparent), transparent 42rem),
|
||||
linear-gradient(135deg, color-mix(in srgb, var(--bg-app) 28%, transparent), color-mix(in srgb, var(--bg-app) 62%, transparent)),
|
||||
var(--asset-botanical-corner),
|
||||
radial-gradient(circle at 76% 10%, var(--botanical-glow), transparent 30rem);
|
||||
background-repeat: no-repeat;
|
||||
background-position: var(--nav-cur) 0, center top, center bottom, center top, right top, center top;
|
||||
background-size: 1px 100%, 100% 52rem, 100% 62rem, 100% 46rem, min(66vw, 980px) auto, 100% 46rem;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav,
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-strong);
|
||||
body[data-role] .vg-shell--botanical .vg-nav {
|
||||
top: auto;
|
||||
padding: var(--sp-5) var(--sp-3);
|
||||
background:
|
||||
linear-gradient(180deg, color-mix(in srgb, var(--bg-surface) 34%, transparent), color-mix(in srgb, var(--bg-app) 54%, transparent)),
|
||||
var(--asset-botanical-sidebar) center bottom / auto 72% no-repeat,
|
||||
color-mix(in srgb, var(--bg-surface) 62%, transparent);
|
||||
color: var(--text-strong);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__label,
|
||||
body[data-role="admin"] .vg-nav__ethic,
|
||||
body[data-role="instructor"] .vg-nav__label,
|
||||
body[data-role="instructor"] .vg-nav__ethic {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__label,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__ethic {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item,
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item {
|
||||
position: relative;
|
||||
isolation: isolate;
|
||||
overflow: hidden;
|
||||
min-height: 50px;
|
||||
padding: 10px 12px;
|
||||
flex-direction: row;
|
||||
justify-content: flex-start;
|
||||
gap: 11px;
|
||||
min-height: 0;
|
||||
padding: 9px 12px;
|
||||
border: 1px solid transparent;
|
||||
color: var(--text-body);
|
||||
font-size: 14px;
|
||||
font-size: 15px;
|
||||
text-align: left;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item:hover,
|
||||
body[data-role="instructor"] .vg-nav__item:hover {
|
||||
background: var(--bg-surface-2);
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item:hover {
|
||||
border-color: color-mix(in srgb, var(--botanical-gold) 18%, var(--border-subtle));
|
||||
background: color-mix(in srgb, var(--bg-surface) 86%, var(--bg-tint));
|
||||
color: var(--text-strong);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item .vg-nav__ic,
|
||||
body[data-role="instructor"] .vg-nav__item .vg-nav__ic {
|
||||
color: var(--text-muted);
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
padding-right: 58px;
|
||||
border-color: color-mix(in srgb, var(--botanical-gold) 34%, var(--border-subtle));
|
||||
background: linear-gradient(
|
||||
112deg,
|
||||
color-mix(in srgb, var(--bg-surface) 94%, var(--botanical-gold)),
|
||||
color-mix(in srgb, var(--bg-surface-2) 78%, transparent)
|
||||
);
|
||||
color: var(--text-strong);
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 84%, transparent);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active,
|
||||
body[data-role="instructor"] .vg-nav__item.is-active {
|
||||
background: var(--accent-tint);
|
||||
color: var(--accent-deep);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item.is-active .vg-nav__ic,
|
||||
body[data-role="instructor"] .vg-nav__item.is-active .vg-nav__ic {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active .vg-nav__ic {
|
||||
color: var(--accent);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__foot,
|
||||
body[data-role="instructor"] .vg-nav__foot {
|
||||
border-top-color: var(--hair);
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
z-index: -1;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 58px;
|
||||
height: 100%;
|
||||
background: var(--asset-botanical-leaf) right -20px bottom -42px / 76px 96px no-repeat;
|
||||
opacity: 0.28;
|
||||
pointer-events: none;
|
||||
display: block;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__foot {
|
||||
border-top-color: var(--botanical-hair);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
body[data-role="admin"] .vg-shell__body,
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
--nav-cur: var(--nav-w-collapsed);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav,
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
body[data-role] .vg-shell--botanical .vg-nav {
|
||||
padding: var(--sp-4) var(--sp-2);
|
||||
background-image: none;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item,
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
min-height: 46px;
|
||||
justify-content: center;
|
||||
padding: 11px;
|
||||
}
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
body[data-role="admin"] .vg-shell__body,
|
||||
body[data-role="instructor"] .vg-shell__body {
|
||||
display: block;
|
||||
background-image: none;
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-rows: 58px minmax(0, 1fr);
|
||||
background-image:
|
||||
radial-gradient(circle at 74% 12%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 22rem),
|
||||
radial-gradient(circle at 18% 82%, color-mix(in srgb, var(--botanical-gold) 14%, transparent), transparent 28rem),
|
||||
var(--asset-botanical-corner);
|
||||
background-repeat: no-repeat;
|
||||
background-position: right top, left bottom, right -140px bottom -80px;
|
||||
background-size: 100% 34rem, 100% 38rem, 430px auto;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav,
|
||||
body[data-role="instructor"] .vg-nav {
|
||||
position: sticky;
|
||||
top: var(--topbar-h);
|
||||
body[data-role] .vg-shell--botanical .vg-nav {
|
||||
position: relative;
|
||||
top: auto;
|
||||
z-index: 29;
|
||||
height: 58px;
|
||||
padding: 7px max(12px, env(safe-area-inset-left)) 7px max(12px, env(safe-area-inset-right));
|
||||
border-bottom: 1px solid var(--hair);
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
border-bottom: 1px solid var(--botanical-hair);
|
||||
background: color-mix(in srgb, var(--bg-surface) 72%, transparent);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item,
|
||||
body[data-role="instructor"] .vg-nav__item {
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item,
|
||||
body[data-role] .vg-shell--botanical .vg-nav__item.is-active {
|
||||
min-width: 112px;
|
||||
height: 44px;
|
||||
justify-content: center;
|
||||
padding: 0 14px;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
body[data-role="admin"] .vg-nav__item span:not(.vg-nav__ic),
|
||||
body[data-role="instructor"] .vg-nav__item span:not(.vg-nav__ic) {
|
||||
display: inline;
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { Surface } from "./Surface";
|
||||
|
||||
export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 그림자 제거(헤어라인만) */
|
||||
|
|
@ -10,17 +11,9 @@ export interface CardProps extends HTMLAttributes<HTMLDivElement> {
|
|||
|
||||
/** 카드: 헤어라인 + 미세 그림자. 상단 강조선 금지. radius 8px. §7.3 */
|
||||
export function Card({ flat, tint, className, children, ...rest }: CardProps) {
|
||||
const cls = [
|
||||
"vg-card",
|
||||
flat ? "vg-card--flat" : "",
|
||||
tint ? "vg-card--tint" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className={cls} {...rest}>
|
||||
<Surface className={["vg-card", className ?? ""].filter(Boolean).join(" ")} flat={flat} tint={tint} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
26
apps/web/src/components/ui/EmptyState.tsx
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export interface EmptyStateProps extends Omit<
|
||||
HTMLAttributes<HTMLDivElement>,
|
||||
"title"
|
||||
> {
|
||||
title: ReactNode;
|
||||
description: ReactNode;
|
||||
descriptionAs?: "span" | "p" | "div";
|
||||
}
|
||||
|
||||
/** 데이터 없음·준비 중 상태의 공통 의미 구조. 배치와 표면은 호출 화면이 소유한다. */
|
||||
export function EmptyState({
|
||||
title,
|
||||
description,
|
||||
descriptionAs: Description = "span",
|
||||
className = "vg-empty",
|
||||
...rest
|
||||
}: EmptyStateProps) {
|
||||
return (
|
||||
<div className={className} {...rest}>
|
||||
<b className="vg-empty__title">{title}</b>
|
||||
<Description className="vg-empty__desc">{description}</Description>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
import { Surface } from "./Surface";
|
||||
|
||||
export interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
||||
/** 그림자 제거 */
|
||||
|
|
@ -10,17 +11,9 @@ export interface PanelProps extends HTMLAttributes<HTMLDivElement> {
|
|||
|
||||
/** 패널: 카드보다 큰 radius(12px) + 넉넉한 패딩(32px). §7.3 */
|
||||
export function Panel({ flat, tint, className, children, ...rest }: PanelProps) {
|
||||
const cls = [
|
||||
"vg-panel",
|
||||
flat ? "vg-panel--flat" : "",
|
||||
tint ? "vg-panel--tint" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
return (
|
||||
<div className={cls} {...rest}>
|
||||
<Surface className={["vg-panel", className ?? ""].filter(Boolean).join(" ")} flat={flat} tint={tint} {...rest}>
|
||||
{children}
|
||||
</div>
|
||||
</Surface>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,51 +0,0 @@
|
|||
import type { ReactNode } from "react";
|
||||
import { Kicker } from "./Kicker";
|
||||
|
||||
export interface SectionHeadProps {
|
||||
/** kicker 텍스트 (상단 소제목) */
|
||||
kicker?: ReactNode;
|
||||
/** kicker dot 표시 */
|
||||
dot?: boolean;
|
||||
/** 섹션 제목 (h2) */
|
||||
title?: ReactNode;
|
||||
/** 보조 설명 */
|
||||
desc?: ReactNode;
|
||||
/** 제목 우측 액션 영역 */
|
||||
action?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** SectionHead — kicker + 제목 + 설명. 한 화면 한 메시지의 위계 헤더. */
|
||||
export function SectionHead({
|
||||
kicker,
|
||||
dot = true,
|
||||
title,
|
||||
desc,
|
||||
action,
|
||||
className,
|
||||
}: SectionHeadProps) {
|
||||
const cls = ["vg-sechead", className ?? ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={cls}>
|
||||
{kicker ? (
|
||||
<div className="vg-sechead__kicker">
|
||||
<Kicker dot={dot}>{kicker}</Kicker>
|
||||
</div>
|
||||
) : null}
|
||||
{(title || action) && (
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
alignItems: "baseline",
|
||||
justifyContent: "space-between",
|
||||
gap: "var(--sp-4)",
|
||||
}}
|
||||
>
|
||||
{title ? <h2 className="vg-sechead__title">{title}</h2> : <span />}
|
||||
{action}
|
||||
</div>
|
||||
)}
|
||||
{desc ? <div className="vg-sechead__desc">{desc}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
import { Fragment } from "react";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export interface Stat {
|
||||
/** 숫자/값 (tabular 표기) */
|
||||
num: ReactNode;
|
||||
/** 라벨 */
|
||||
label: ReactNode;
|
||||
}
|
||||
|
||||
export interface StatLineProps {
|
||||
stats: Stat[];
|
||||
/** 항목 사이 세로 구분선 */
|
||||
separators?: boolean;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/** StatLine — 요약 스탯 가로 나열. 숫자는 tabular-nums. */
|
||||
export function StatLine({ stats, separators = true, className }: StatLineProps) {
|
||||
const cls = ["vg-statline", className ?? ""].filter(Boolean).join(" ");
|
||||
return (
|
||||
<div className={cls}>
|
||||
{stats.map((s, i) => (
|
||||
<Fragment key={i}>
|
||||
{separators && i > 0 ? (
|
||||
<span className="vg-statline__sep" aria-hidden="true" />
|
||||
) : null}
|
||||
<span className="vg-statline__item">
|
||||
<span className="vg-statline__num tabular">{s.num}</span>
|
||||
<span className="vg-statline__lab">{s.label}</span>
|
||||
</span>
|
||||
</Fragment>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/ui/Surface.tsx
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import type { HTMLAttributes, ReactNode } from "react";
|
||||
|
||||
export type SurfaceVariant = "panel" | "inset" | "interactive";
|
||||
|
||||
export interface SurfaceOptions {
|
||||
variant?: SurfaceVariant;
|
||||
flat?: boolean;
|
||||
tint?: boolean;
|
||||
}
|
||||
|
||||
/** raw element(button/li/section 포함)도 같은 표면 계약을 쓰게 하는 class builder. */
|
||||
export function surfaceClassName(
|
||||
className?: string,
|
||||
{ variant = "panel", flat, tint }: SurfaceOptions = {},
|
||||
) {
|
||||
return [
|
||||
"vg-surface",
|
||||
`vg-surface--${variant}`,
|
||||
flat ? "vg-surface--flat" : "",
|
||||
tint ? "vg-surface--tint" : "",
|
||||
className ?? "",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
export interface SurfaceProps extends HTMLAttributes<HTMLDivElement>, SurfaceOptions {
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Surface — 배경·헤어라인·그림자·backdrop-filter의 단일 소유자.
|
||||
* 페이지 CSS는 이 컴포넌트의 배치와 내부 타이포만 소유한다.
|
||||
*/
|
||||
export function Surface({
|
||||
variant = "panel",
|
||||
flat,
|
||||
tint,
|
||||
className,
|
||||
children,
|
||||
...rest
|
||||
}: SurfaceProps) {
|
||||
return (
|
||||
<div
|
||||
className={surfaceClassName(className, { variant, flat, tint })}
|
||||
data-surface={variant}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -13,11 +13,14 @@ export type { CardProps } from "./Card";
|
|||
export { Panel } from "./Panel";
|
||||
export type { PanelProps } from "./Panel";
|
||||
|
||||
export { Surface, surfaceClassName } from "./Surface";
|
||||
export type { SurfaceProps, SurfaceOptions, SurfaceVariant } from "./Surface";
|
||||
|
||||
export { Kicker } from "./Kicker";
|
||||
export type { KickerProps } from "./Kicker";
|
||||
|
||||
export { SectionHead } from "./SectionHead";
|
||||
export type { SectionHeadProps } from "./SectionHead";
|
||||
export { EmptyState } from "./EmptyState";
|
||||
export type { EmptyStateProps } from "./EmptyState";
|
||||
|
||||
export { Badge } from "./Badge";
|
||||
export type { BadgeProps, BadgeTone } from "./Badge";
|
||||
|
|
@ -25,9 +28,6 @@ export type { BadgeProps, BadgeTone } from "./Badge";
|
|||
export { Dot } from "./Dot";
|
||||
export type { DotProps, DotTone } from "./Dot";
|
||||
|
||||
export { StatLine } from "./StatLine";
|
||||
export type { StatLineProps, Stat } from "./StatLine";
|
||||
|
||||
export { ProgressBar } from "./ProgressBar";
|
||||
export type { ProgressBarProps, ProgressTone } from "./ProgressBar";
|
||||
|
||||
|
|
|
|||
|
|
@ -83,16 +83,18 @@
|
|||
}
|
||||
|
||||
.vg-btn--primary {
|
||||
background: var(--accent);
|
||||
background: linear-gradient(105deg, color-mix(in srgb, var(--accent) 90%, var(--bg-surface)), var(--accent));
|
||||
color: var(--text-on-accent);
|
||||
border-color: color-mix(in srgb, var(--botanical-gold) 24%, var(--accent));
|
||||
box-shadow: inset 0 1px 0 color-mix(in srgb, var(--bg-surface) 32%, transparent);
|
||||
}
|
||||
.vg-btn--primary:hover:not(:disabled) {
|
||||
background: var(--accent-deep);
|
||||
}
|
||||
.vg-btn--secondary {
|
||||
background: var(--bg-surface);
|
||||
background: var(--surface-gradient);
|
||||
color: var(--accent);
|
||||
border-color: var(--accent);
|
||||
border-color: color-mix(in srgb, var(--accent) 62%, var(--botanical-hair));
|
||||
}
|
||||
.vg-btn--secondary:hover:not(:disabled) {
|
||||
background: var(--accent-tint);
|
||||
|
|
@ -107,42 +109,61 @@
|
|||
}
|
||||
.vg-btn--danger {
|
||||
background: var(--crit-solid);
|
||||
color: #fff;
|
||||
color: var(--text-on-accent);
|
||||
}
|
||||
.vg-btn--danger:hover:not(:disabled) {
|
||||
background: var(--crit-text);
|
||||
}
|
||||
|
||||
/* ── Card / Panel §7.3 (헤어라인 + 미세 그림자, 상단 강조선 금지) ── */
|
||||
.vg-card {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
/* ── Surface SSOT ── */
|
||||
.vg-surface.vg-surface--panel {
|
||||
background: var(--glass-specular), var(--glass-surface);
|
||||
border: 1px solid var(--glass-border);
|
||||
border-radius: var(--radius);
|
||||
padding: var(--sp-5);
|
||||
box-shadow: var(--shadow-sm);
|
||||
box-shadow: var(--glass-edge-shadow), var(--glass-shadow);
|
||||
-webkit-backdrop-filter: var(--glass-blur);
|
||||
backdrop-filter: var(--glass-blur);
|
||||
}
|
||||
.vg-card--flat {
|
||||
.vg-surface.vg-surface--inset {
|
||||
background: var(--glass-specular-inset), var(--glass-surface-inset);
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: var(--glass-inset-shadow);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(110%);
|
||||
backdrop-filter: blur(12px) saturate(110%);
|
||||
}
|
||||
.vg-surface.vg-surface--interactive {
|
||||
background: var(--glass-specular-inset), var(--glass-surface-inset);
|
||||
border: 1px solid var(--glass-inset-border);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--glass-inset-shadow);
|
||||
-webkit-backdrop-filter: blur(12px) saturate(110%);
|
||||
backdrop-filter: blur(12px) saturate(110%);
|
||||
transition:
|
||||
background var(--dur-base) var(--ease-out),
|
||||
border-color var(--dur-base) var(--ease-out),
|
||||
transform var(--dur-fast) var(--ease-out);
|
||||
}
|
||||
.vg-surface.vg-surface--interactive:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 48%, var(--glass-border));
|
||||
background: var(--glass-specular), var(--glass-surface-strong);
|
||||
}
|
||||
.vg-surface.vg-surface--flat {
|
||||
box-shadow: none;
|
||||
}
|
||||
.vg-card--tint {
|
||||
.vg-surface.vg-surface--tint {
|
||||
background: var(--bg-tint);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Card / Panel 크기 계약. 표면은 위 Surface만 소유한다. ── */
|
||||
.vg-card {
|
||||
padding: var(--sp-5);
|
||||
}
|
||||
.vg-panel {
|
||||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--sp-6);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
.vg-panel--flat {
|
||||
box-shadow: none;
|
||||
}
|
||||
.vg-panel--tint {
|
||||
background: var(--bg-tint);
|
||||
border-color: transparent;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
/* ── Badge (배경 틴트 + 텍스트, 좌측바 없음) ── */
|
||||
|
|
|
|||
|
|
@ -818,6 +818,29 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/personas/sources/upload": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/**
|
||||
* Upload Persona Source Route
|
||||
* @description 자유 양식 엑셀/CSV 업로드 → 텍스트 변환 → 기존 source 등록 경로 재사용 (P4).
|
||||
*
|
||||
* 업로드 원본 바이트는 이 핸들러 메모리에서만 파싱하고 저장하지 않는다(원본 파기).
|
||||
* 파생 텍스트만 기존 마스킹·hash-only 증거·sanitized chunk 경로로 등록된다.
|
||||
*/
|
||||
post: operations["upload_persona_source_route_personas_sources_upload_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/personas/{persona_id}": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1972,6 +1995,27 @@ export interface components {
|
|||
*/
|
||||
file: string;
|
||||
};
|
||||
/** Body_upload_persona_source_route_personas_sources_upload_post */
|
||||
Body_upload_persona_source_route_personas_sources_upload_post: {
|
||||
/**
|
||||
* File
|
||||
* Format: binary
|
||||
*/
|
||||
file: string;
|
||||
/**
|
||||
* Source Kind
|
||||
* @default mixed_notes
|
||||
* @enum {string}
|
||||
*/
|
||||
source_kind: "client_record" | "textbook_guide" | "mixed_notes";
|
||||
/**
|
||||
* Source Note
|
||||
* @default
|
||||
*/
|
||||
source_note: string;
|
||||
/** Title */
|
||||
title?: string | null;
|
||||
};
|
||||
/** ChunkOut */
|
||||
ChunkOut: {
|
||||
/** Behavior Cue */
|
||||
|
|
@ -2433,6 +2477,11 @@ export interface components {
|
|||
persona_code: string;
|
||||
/** Persona Name */
|
||||
persona_name: string;
|
||||
/**
|
||||
* Rapport Percent
|
||||
* @default 0
|
||||
*/
|
||||
rapport_percent: number;
|
||||
/**
|
||||
* Review Ready Sessions
|
||||
* @default 0
|
||||
|
|
@ -3467,14 +3516,22 @@ export interface components {
|
|||
SessionDetailResponse: {
|
||||
/** Case Id */
|
||||
case_id: string;
|
||||
/**
|
||||
* Duration Limit Seconds
|
||||
* @default 0
|
||||
*/
|
||||
duration_limit_seconds: number;
|
||||
/** Effective Openness */
|
||||
effective_openness: number;
|
||||
/** Ended At */
|
||||
ended_at?: string | null;
|
||||
/** Goal Stages */
|
||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||
/** Persona Code */
|
||||
persona_code: string;
|
||||
/** Persona Name */
|
||||
persona_name: string;
|
||||
progress?: components["schemas"]["SessionProgress"] | null;
|
||||
/**
|
||||
* Review Ready
|
||||
* @default false
|
||||
|
|
@ -3498,6 +3555,11 @@ export interface components {
|
|||
theory_mode: string;
|
||||
/** Turns */
|
||||
turns?: components["schemas"]["SessionDetailTurn"][];
|
||||
/**
|
||||
* Warning Before End Seconds
|
||||
* @default 0
|
||||
*/
|
||||
warning_before_end_seconds: number;
|
||||
};
|
||||
/** SessionDetailTurn */
|
||||
SessionDetailTurn: {
|
||||
|
|
@ -3575,6 +3637,34 @@ export interface components {
|
|||
*/
|
||||
turns_evaluated: number;
|
||||
};
|
||||
/**
|
||||
* SessionProgress
|
||||
* @description 회기 진행 상세(P2). 내부 원값 명칭 대신 학습자-안전 파생 %만 노출한다.
|
||||
*/
|
||||
SessionProgress: {
|
||||
/**
|
||||
* Openness Percent
|
||||
* @default 0
|
||||
*/
|
||||
openness_percent: number;
|
||||
/**
|
||||
* Rapport Delta Percent
|
||||
* @default 0
|
||||
*/
|
||||
rapport_delta_percent: number;
|
||||
/**
|
||||
* Rapport Percent
|
||||
* @default 0
|
||||
*/
|
||||
rapport_percent: number;
|
||||
/**
|
||||
* Resistance Percent
|
||||
* @default 0
|
||||
*/
|
||||
resistance_percent: number;
|
||||
/** Stages */
|
||||
stages?: components["schemas"]["SessionStageProgress"][];
|
||||
};
|
||||
/** SessionReviewResponse */
|
||||
SessionReviewResponse: {
|
||||
/** Audiourl */
|
||||
|
|
@ -3656,8 +3746,36 @@ export interface components {
|
|||
/** Title */
|
||||
title: string;
|
||||
};
|
||||
/**
|
||||
* SessionStageProgress
|
||||
* @description 단계별 누적 게이지(P2). 상태머신 결정론 수치의 파생값만 담는다.
|
||||
*/
|
||||
SessionStageProgress: {
|
||||
/**
|
||||
* Achieved
|
||||
* @default false
|
||||
*/
|
||||
achieved: boolean;
|
||||
/**
|
||||
* Is Goal
|
||||
* @default false
|
||||
*/
|
||||
is_goal: boolean;
|
||||
/**
|
||||
* Percent
|
||||
* @default 0
|
||||
*/
|
||||
percent: number;
|
||||
/**
|
||||
* Stage
|
||||
* @enum {string}
|
||||
*/
|
||||
stage: "라포" | "탐색" | "개입" | "정리";
|
||||
};
|
||||
/** SessionStartRequest */
|
||||
SessionStartRequest: {
|
||||
/** Goal Stages */
|
||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||
/**
|
||||
* Persona Code
|
||||
* @example P1
|
||||
|
|
@ -3679,8 +3797,15 @@ export interface components {
|
|||
* @default false
|
||||
*/
|
||||
degraded: boolean;
|
||||
/**
|
||||
* Duration Limit Seconds
|
||||
* @default 0
|
||||
*/
|
||||
duration_limit_seconds: number;
|
||||
/** Effective Openness */
|
||||
effective_openness: number;
|
||||
/** Goal Stages */
|
||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||
/** Recall Summary */
|
||||
recall_summary?: string | null;
|
||||
/** Session Id */
|
||||
|
|
@ -3692,6 +3817,16 @@ export interface components {
|
|||
* @enum {string}
|
||||
*/
|
||||
stage: "라포" | "탐색" | "개입" | "정리";
|
||||
/**
|
||||
* Started At
|
||||
* @default
|
||||
*/
|
||||
started_at: string;
|
||||
/**
|
||||
* Warning Before End Seconds
|
||||
* @default 0
|
||||
*/
|
||||
warning_before_end_seconds: number;
|
||||
};
|
||||
/** SessionTeacherReviewStatus */
|
||||
SessionTeacherReviewStatus: {
|
||||
|
|
@ -4057,8 +4192,9 @@ export interface components {
|
|||
/**
|
||||
* Appropriateness
|
||||
* @default neutral
|
||||
* @enum {string}
|
||||
*/
|
||||
appropriateness: string;
|
||||
appropriateness: "pos" | "warn" | "neutral";
|
||||
/** Appropriateness Note */
|
||||
appropriateness_note?: string | null;
|
||||
/** Client State Read */
|
||||
|
|
@ -4117,6 +4253,7 @@ export interface components {
|
|||
effective_openness: number;
|
||||
/** Output Error */
|
||||
output_error?: string | null;
|
||||
progress?: components["schemas"]["SessionProgress"] | null;
|
||||
/**
|
||||
* Safety Flagged
|
||||
* @default false
|
||||
|
|
@ -5915,6 +6052,42 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
upload_persona_source_route_personas_sources_upload_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"multipart/form-data": components["schemas"]["Body_upload_persona_source_route_personas_sources_upload_post"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["PersonaSourceDocumentResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
archive_persona_route_personas__persona_id__delete: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
|
|||
|
|
@ -197,6 +197,9 @@ export type PersonaDraftGenerateResponse = ApiSchema<"PersonaDraftGenerateRespon
|
|||
|
||||
/** POST /sessions — sessions.py SessionStartResponse */
|
||||
export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
|
||||
/** P2 단계 누적 게이지·상세 수치 — session_read_model.SessionProgress */
|
||||
export type SessionProgress = ApiSchema<"SessionProgress">;
|
||||
export type SessionStageProgress = ApiSchema<"SessionStageProgress">;
|
||||
|
||||
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
|
||||
export type TurnResponse = ApiSchema<"TurnResponse">;
|
||||
|
|
@ -279,6 +282,8 @@ export interface SessionStreamDone {
|
|||
crisis_resource?: CrisisResource | null;
|
||||
conversation_stopped?: boolean;
|
||||
output_error?: string | null;
|
||||
/** P2 단계 누적 게이지·상세 수치 */
|
||||
progress?: SessionProgress | null;
|
||||
}
|
||||
|
||||
function safeParse(data: string): unknown {
|
||||
|
|
@ -348,6 +353,7 @@ export async function openSessionStream(
|
|||
crisis_resource: parsed.crisis_resource,
|
||||
conversation_stopped: parsed.conversation_stopped,
|
||||
output_error: parsed.output_error,
|
||||
progress: parsed.progress ?? null,
|
||||
};
|
||||
handlers.onDone?.(donePayload);
|
||||
return;
|
||||
|
|
@ -419,6 +425,25 @@ export const personaReviewApi = {
|
|||
api.post<PersonaReviewSummary>("/personas/drafts", payload),
|
||||
createSource: (payload: PersonaSourceDocumentRequest) =>
|
||||
api.post<PersonaSourceDocumentResponse>("/personas/sources", payload),
|
||||
/** P4: 자유 양식 엑셀/CSV 업로드 → 서버 변환·등록 (원본은 서버에 저장되지 않음) */
|
||||
uploadSource: async (
|
||||
file: File,
|
||||
options: { source_kind?: string; title?: string; source_note?: string } = {},
|
||||
) => {
|
||||
const form = new FormData();
|
||||
form.append("file", file);
|
||||
if (options.source_kind) form.append("source_kind", options.source_kind);
|
||||
if (options.title) form.append("title", options.title);
|
||||
if (options.source_note) form.append("source_note", options.source_note);
|
||||
const res = await fetch(joinUrl("/personas/sources/upload"), {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { Accept: "application/json" },
|
||||
body: form,
|
||||
});
|
||||
if (!res.ok) throw await parseError(res);
|
||||
return (await res.json()) as PersonaSourceDocumentResponse;
|
||||
},
|
||||
generateDraft: (payload: PersonaDraftGenerateRequest) =>
|
||||
api.post<PersonaDraftGenerateResponse>("/personas/drafts/generate", payload),
|
||||
getDraft: (personaId: string) =>
|
||||
|
|
@ -464,8 +489,12 @@ export const sessionApi = {
|
|||
},
|
||||
get: (sessionId: string) =>
|
||||
api.get<SessionDetailResponse>(`/sessions/${encodeURIComponent(sessionId)}`),
|
||||
start: (persona_code: string, theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic") =>
|
||||
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode }),
|
||||
start: (
|
||||
persona_code: string,
|
||||
theory_mode: "humanistic" | "cbt" | "integrative" = "humanistic",
|
||||
goal_stages: SessionStage[] = [],
|
||||
) =>
|
||||
api.post<SessionStartResponse>("/sessions", { persona_code, theory_mode, goal_stages }),
|
||||
turn: (sessionId: string, text: string) =>
|
||||
api.post<TurnResponse>(`/sessions/${encodeURIComponent(sessionId)}/turn`, { text }),
|
||||
liveCoach: (sessionId: string, payload: LiveCoachRequest) =>
|
||||
|
|
|
|||
|
|
@ -44,6 +44,35 @@ export function formatPercent(ratio: number, fractionDigits = 0): string {
|
|||
return `${pct.toFixed(fractionDigits)}%`;
|
||||
}
|
||||
|
||||
/** nullable 0~1 비율을 화면별 빈 상태 문구와 함께 표시한다. */
|
||||
export function formatOptionalPercent(
|
||||
ratio: number | null | undefined,
|
||||
fallback = "-",
|
||||
suffix = "%",
|
||||
): string {
|
||||
if (typeof ratio !== "number" || !Number.isFinite(ratio)) return fallback;
|
||||
return `${Math.round(clamp01(ratio) * 100)}${suffix}`;
|
||||
}
|
||||
|
||||
/** nullable 0~1 변화량을 백분율포인트로 표시한다. */
|
||||
export function formatPercentagePointDelta(
|
||||
delta: number | null | undefined,
|
||||
fallback = "-",
|
||||
): string {
|
||||
if (typeof delta !== "number" || !Number.isFinite(delta)) return fallback;
|
||||
const sign = delta > 0 ? "+" : "";
|
||||
return `${sign}${Math.round(delta * 100)}%p`;
|
||||
}
|
||||
|
||||
/** -1~1 상관·라포 값을 학습자용 0~100%로 변환한다. */
|
||||
export function formatBipolarPercent(
|
||||
value: number | null | undefined,
|
||||
fallback = "-",
|
||||
): string {
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
||||
return `${Math.round(clamp01((value + 1) / 2) * 100)}%`;
|
||||
}
|
||||
|
||||
/** 변화량 표기: +6 / -3 / 0 (부호 명시). */
|
||||
export function formatDelta(delta: number): string {
|
||||
if (delta > 0) return `+${delta}`;
|
||||
|
|
@ -72,6 +101,33 @@ export function formatDateISO(input: string | Date): string {
|
|||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** ISO/Date를 한국어 월·일·시·분으로 표시한다. */
|
||||
export function formatDateTimeKo(
|
||||
input: string | Date | null | undefined,
|
||||
fallback = "-",
|
||||
): string {
|
||||
if (!input) return fallback;
|
||||
const date = typeof input === "string" ? new Date(input) : input;
|
||||
if (Number.isNaN(date.getTime()))
|
||||
return typeof input === "string" ? input : fallback;
|
||||
return date.toLocaleString("ko-KR", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Unix epoch 초를 한국어 월·일·시·분으로 표시한다. */
|
||||
export function formatUnixSecondsKo(
|
||||
seconds: number | null | undefined,
|
||||
fallback = "-",
|
||||
): string {
|
||||
if (typeof seconds !== "number" || !Number.isFinite(seconds) || seconds <= 0)
|
||||
return fallback;
|
||||
return formatDateTimeKo(new Date(seconds * 1000), fallback);
|
||||
}
|
||||
|
||||
/** 0~1 클램프. */
|
||||
export function clamp01(v: number): number {
|
||||
if (v < 0) return 0;
|
||||
|
|
|
|||