전 저장소 리팩터링과 SSOT 정비

This commit is contained in:
Yun Chan 2026-07-15 21:31:30 +09:00
parent 14ecbd4e7d
commit 3dfddcac6f
173 changed files with 19679 additions and 6952 deletions

2
.gitignore vendored
View file

@ -41,6 +41,8 @@ apps/api/uploads/
.idea/
.vscode/
postgres-data/
.wrangler/
public-runtime-watchdog.failcount
# 임시 실행/로그 (P1 빌드)
*.out

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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,

View file

@ -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)

View file

@ -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:

View file

@ -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",

View file

@ -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,

View file

@ -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,

View file

@ -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)

View file

@ -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

View file

@ -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"

View 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

View file

@ -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:

View 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,
}

View file

@ -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

View file

@ -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": "그 이름",

View file

@ -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(

View 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

View file

@ -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

View file

@ -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;">
이후 가입 승인 요청과 회기 검토 요청도 같은 메일 템플릿과 발송 큐를 사용합니다.

View file

@ -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

View file

@ -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 표현)

View file

@ -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,

View file

@ -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,

View 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"]

View file

@ -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"])

View file

@ -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 []),
),
)

View file

@ -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

View file

@ -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: "초점 이탈/융합",
}

View file

@ -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 = [
"내담자 역할로 응답하겠습니다. 엄마가 가보라고 해서요.",

View file

@ -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")

View file

@ -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()

View file

@ -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()

View file

@ -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]] = []

View file

@ -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:

View 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()

View 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()

View 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()

View file

@ -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, "수정한 주호소")

View 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()

View file

@ -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",

View file

@ -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(

View file

@ -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()

View file

@ -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)

View file

@ -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 기반으로 동작하고,

View file

@ -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.

View file

@ -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");
}
});

View 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,
});
}
}
});

View file

@ -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();
});
});

View file

@ -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");
});
});

View file

@ -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);
});
});

View 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();
});
});

View file

@ -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);
});

View file

@ -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 }) => {

View file

@ -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) {

View file

@ -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");

View file

@ -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
View 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"]
}

File diff suppressed because it is too large Load diff

View file

@ -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"
}

View file

@ -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 },

Binary file not shown.

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 108 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View 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 소유권이 분리되어 있습니다.",
);

View 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",
]);

View file

@ -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={

View 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>
);
}

View 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;
}
}

View file

@ -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>

View file

@ -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"

View file

@ -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;
/**

View file

@ -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}

View file

@ -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" },

View file

@ -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 ? "라이트 모드" : "다크 모드"}
>

View file

@ -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;
}
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View 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>
);
}

View file

@ -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";

View file

@ -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 (배경 틴트 + 텍스트, 좌측바 없음) ── */

View file

@ -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;

View file

@ -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) =>

View file

@ -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;

Some files were not shown because too many files have changed in this diff Show more