개선관리 요구사항과 Google 로그인을 완료
This commit is contained in:
parent
cc0a15b7c6
commit
2a39636163
112 changed files with 10166 additions and 527 deletions
60
apps/api/app/access_logging.py
Normal file
60
apps/api/app/access_logging.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""Uvicorn access log에서 인증 콜백 비밀값을 제거한다.
|
||||
|
||||
Uvicorn은 ASGI scope의 path와 query string을 합쳐 ``uvicorn.access``의
|
||||
세 번째 포맷 인자로 기록한다. Google OAuth 콜백에는 일회용 authorization
|
||||
code와 CSRF state가 query에 있으므로, 애플리케이션 라우터가 안전하게 로깅해도
|
||||
기본 access log에서 먼저 노출될 수 있다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
|
||||
OAUTH_CALLBACK_PATH = "/auth/callback"
|
||||
|
||||
|
||||
def redact_access_log_target(target: Any) -> Any:
|
||||
"""OAuth callback의 query 전체를 제거하고 다른 request target은 보존한다."""
|
||||
if not isinstance(target, str):
|
||||
return target
|
||||
path, separator, _query = target.partition("?")
|
||||
if separator and path.rstrip("/") == OAUTH_CALLBACK_PATH:
|
||||
return path
|
||||
return target
|
||||
|
||||
|
||||
class UvicornAccessLogRedactionFilter(logging.Filter):
|
||||
"""Uvicorn의 구조화된 access-log 인자에서 request target만 정제한다."""
|
||||
|
||||
def filter(self, record: logging.LogRecord) -> bool:
|
||||
args = record.args
|
||||
# Uvicorn 0.30.x access 포맷:
|
||||
# (client_addr, method, path_with_query, http_version, status_code)
|
||||
if isinstance(args, tuple) and len(args) >= 3:
|
||||
target = args[2]
|
||||
redacted = redact_access_log_target(target)
|
||||
if redacted != target:
|
||||
record.args = (*args[:2], redacted, *args[3:])
|
||||
return True
|
||||
|
||||
|
||||
_ACCESS_LOG_FILTER = UvicornAccessLogRedactionFilter()
|
||||
|
||||
|
||||
def install_uvicorn_access_log_redaction() -> None:
|
||||
"""현재 프로세스의 Uvicorn access logger에 필터를 한 번만 설치한다."""
|
||||
access_logger = logging.getLogger("uvicorn.access")
|
||||
if not any(
|
||||
isinstance(existing, UvicornAccessLogRedactionFilter)
|
||||
for existing in access_logger.filters
|
||||
):
|
||||
access_logger.addFilter(_ACCESS_LOG_FILTER)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UvicornAccessLogRedactionFilter",
|
||||
"install_uvicorn_access_log_redaction",
|
||||
"redact_access_log_target",
|
||||
]
|
||||
|
|
@ -39,6 +39,7 @@ class SessionUser:
|
|||
consent_at: float | None
|
||||
profile_completed_at: float | None
|
||||
expires_at: float
|
||||
learner_feedback_enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -67,6 +68,7 @@ class ManagedUser:
|
|||
privacy_version: str
|
||||
created_at: float
|
||||
last_seen_at: float
|
||||
learner_feedback_enabled: bool = True
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
|
@ -93,6 +95,7 @@ class ManagedUserMemoryInput:
|
|||
privacy_agreed_at: float | None = None
|
||||
terms_version: str | None = None
|
||||
privacy_version: str | None = None
|
||||
learner_feedback_enabled: bool | None = None
|
||||
reactivate: bool = False
|
||||
|
||||
@classmethod
|
||||
|
|
@ -125,6 +128,7 @@ class ManagedUserMemoryInput:
|
|||
privacy_agreed_at=user.privacy_agreed_at,
|
||||
terms_version=user.terms_version,
|
||||
privacy_version=user.privacy_version,
|
||||
learner_feedback_enabled=user.learner_feedback_enabled,
|
||||
reactivate=reactivate,
|
||||
)
|
||||
|
||||
|
|
@ -140,6 +144,7 @@ class ManagedUserUpsertInput:
|
|||
external_id: str | None = None
|
||||
affiliation: str | None = None
|
||||
account_status: AccountStatus | None = None
|
||||
learner_feedback_enabled: bool | None = None
|
||||
reactivate: bool = False
|
||||
|
||||
|
||||
|
|
@ -162,6 +167,7 @@ class ManagedUserPatch:
|
|||
complete_onboarding: bool = False
|
||||
terms_version: str | None = None
|
||||
privacy_version: str | None = None
|
||||
learner_feedback_enabled: bool | None = None
|
||||
|
||||
|
||||
class InactiveUserError(Exception):
|
||||
|
|
@ -203,6 +209,21 @@ def _normalize_email(email: str) -> str:
|
|||
return email.strip().lower()
|
||||
|
||||
|
||||
def _email_domain(email: str) -> str:
|
||||
normalized = _normalize_email(email)
|
||||
if "@" not in normalized:
|
||||
return ""
|
||||
return normalized.rsplit("@", 1)[1]
|
||||
|
||||
|
||||
def _allowed_email_domain_set() -> set[str]:
|
||||
return {
|
||||
normalized
|
||||
for value in settings.auth_allowed_email_domains
|
||||
if (normalized := str(value).strip().lower().lstrip("@"))
|
||||
}
|
||||
|
||||
|
||||
def _normalize_external_id(external_id: str | None, email: str) -> str:
|
||||
value = (external_id or "").strip().lower()
|
||||
return value or f"email:{_normalize_email(email)}"
|
||||
|
|
@ -270,7 +291,13 @@ def _initial_account_status(
|
|||
normalized_email = _normalize_email(email)
|
||||
if reactivate or normalized_email in _auto_approved_email_set():
|
||||
return "approved"
|
||||
if settings.environment == "dev" and _is_dev_login_external_id(external_id):
|
||||
# 로컬 E2E 자동 승인은 허용된 내부 도메인에만 적용한다. 관리자 exact-email로
|
||||
# 사전등록한 외부 연구참여자의 pending 상태를 dev 로그인이 승격하면 안 된다.
|
||||
if (
|
||||
settings.environment == "dev"
|
||||
and _is_dev_login_external_id(external_id)
|
||||
and _email_domain(normalized_email) in _allowed_email_domain_set()
|
||||
):
|
||||
return "approved"
|
||||
return _account_status(settings.auth_new_user_default_status)
|
||||
|
||||
|
|
@ -332,10 +359,11 @@ async def _runtime_tables_ready(conn) -> bool:
|
|||
'last_seen_at',
|
||||
'account_status',
|
||||
'admin_access',
|
||||
'learner_feedback_enabled',
|
||||
'updated_at'
|
||||
)
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 19
|
||||
HAVING count(*) = 20
|
||||
) AS has_user_columns,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
|
|
@ -362,10 +390,11 @@ async def _runtime_tables_ready(conn) -> bool:
|
|||
'persona_display_name',
|
||||
'persona_difficulty',
|
||||
'prev_rapport_credit',
|
||||
'session_goals'
|
||||
'session_goals',
|
||||
'learner_feedback_enabled'
|
||||
)
|
||||
GROUP BY table_schema, table_name
|
||||
HAVING count(*) = 6
|
||||
HAVING count(*) = 7
|
||||
) AS has_session_columns,
|
||||
EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
|
|
@ -542,10 +571,19 @@ async def ensure_runtime_tables() -> None:
|
|||
"""
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('app.persona_card') IS NOT NULL THEN
|
||||
IF to_regclass('app.persona_card') IS NOT NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_schema = 'app'
|
||||
AND table_name = 'persona_card'
|
||||
AND column_name = 'triggers'
|
||||
) THEN
|
||||
ALTER TABLE app.persona_card
|
||||
ADD COLUMN IF NOT EXISTS triggers JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
END IF;
|
||||
|
||||
IF to_regclass('app.persona_card') IS NOT NULL
|
||||
AND to_regclass('app.persona_voice_map') IS NULL THEN
|
||||
CREATE TABLE IF NOT EXISTS app.persona_voice_map (
|
||||
persona_id UUID NOT NULL,
|
||||
version INT NOT NULL,
|
||||
|
|
@ -564,6 +602,7 @@ async def ensure_runtime_tables() -> None:
|
|||
WHERE table_schema = 'app'
|
||||
AND table_name = 'app_user'
|
||||
AND column_name = 'affiliation'
|
||||
AND column_default IS DISTINCT FROM ''''::text
|
||||
) THEN
|
||||
ALTER TABLE app.app_user ALTER COLUMN affiliation SET DEFAULT '';
|
||||
END IF;
|
||||
|
|
@ -593,6 +632,7 @@ async def ensure_runtime_tables() -> None:
|
|||
ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
ADD COLUMN IF NOT EXISTS account_status TEXT NOT NULL DEFAULT 'approved',
|
||||
ADD COLUMN IF NOT EXISTS admin_access BOOLEAN NOT NULL DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS learner_feedback_enabled BOOLEAN NOT NULL DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
"""
|
||||
)
|
||||
|
|
@ -926,7 +966,8 @@ async def ensure_runtime_tables() -> None:
|
|||
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 session_goals JSONB NOT NULL DEFAULT '[]'::jsonb
|
||||
ADD COLUMN IF NOT EXISTS session_goals JSONB NOT NULL DEFAULT '[]'::jsonb,
|
||||
ADD COLUMN IF NOT EXISTS learner_feedback_enabled BOOLEAN NOT NULL DEFAULT true
|
||||
"""
|
||||
)
|
||||
await conn.execute(
|
||||
|
|
@ -1108,6 +1149,9 @@ def _managed_user_from_row(row) -> ManagedUser:
|
|||
privacy_version=_row_value(row, "privacy_version", "") or "",
|
||||
created_at=_ts(row["created_at"]),
|
||||
last_seen_at=_ts(row["last_seen_at"]),
|
||||
learner_feedback_enabled=bool(
|
||||
_row_value(row, "learner_feedback_enabled", True)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1133,8 +1177,14 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
|
|||
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"),
|
||||
account_status=(
|
||||
"suspended"
|
||||
if current is not None
|
||||
and current.account_status == "suspended"
|
||||
and not data.reactivate
|
||||
else data.account_status
|
||||
or (current.account_status if current else "approved")
|
||||
),
|
||||
cohort_ids=(
|
||||
list(data.cohort_ids)
|
||||
if data.cohort_ids is not None
|
||||
|
|
@ -1209,6 +1259,11 @@ def _memory_upsert_managed_user(data: ManagedUserMemoryInput) -> ManagedUser:
|
|||
),
|
||||
created_at=current.created_at if current else now,
|
||||
last_seen_at=now,
|
||||
learner_feedback_enabled=(
|
||||
data.learner_feedback_enabled
|
||||
if data.learner_feedback_enabled is not None
|
||||
else (current.learner_feedback_enabled if current else True)
|
||||
),
|
||||
)
|
||||
_users[uid] = user
|
||||
_email_index[normalized_email] = uid
|
||||
|
|
@ -1232,6 +1287,7 @@ def _sync_managed_user_sessions(user: ManagedUser) -> None:
|
|||
session.cohort_ids = list(user.cohort_ids)
|
||||
session.consent_at = user.consent_at
|
||||
session.profile_completed_at = user.profile_completed_at
|
||||
session.learner_feedback_enabled = user.learner_feedback_enabled
|
||||
|
||||
|
||||
async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
||||
|
|
@ -1267,6 +1323,11 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
WHEN $7::boolean IS NULL THEN admin_access
|
||||
ELSE $7::boolean
|
||||
END,
|
||||
account_status = CASE
|
||||
WHEN account_status = 'suspended' THEN 'suspended'
|
||||
WHEN $8::text = 'approved' THEN 'approved'
|
||||
ELSE account_status
|
||||
END,
|
||||
last_seen_at = now(),
|
||||
updated_at = now()
|
||||
WHERE user_id = (
|
||||
|
|
@ -1288,6 +1349,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
display_name,
|
||||
role,
|
||||
admin_access,
|
||||
learner_feedback_enabled,
|
||||
account_status,
|
||||
cohort,
|
||||
affiliation,
|
||||
|
|
@ -1319,6 +1381,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
data.affiliation or DEFAULT_AFFILIATION,
|
||||
manual_external_id,
|
||||
desired_admin_access,
|
||||
desired_account_status,
|
||||
)
|
||||
if row is not None:
|
||||
user = _managed_user_from_row(row)
|
||||
|
|
@ -1334,13 +1397,17 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
display_name,
|
||||
role,
|
||||
admin_access,
|
||||
learner_feedback_enabled,
|
||||
cohort,
|
||||
affiliation,
|
||||
account_status,
|
||||
last_seen_at,
|
||||
updated_at
|
||||
)
|
||||
VALUES ($1, $2, $3, $4, COALESCE($9::boolean, false), $5, $6, $8, now(), now())
|
||||
VALUES (
|
||||
$1, $2, $3, $4, COALESCE($9::boolean, false),
|
||||
COALESCE($10::boolean, true), $5, $6, $8, now(), now()
|
||||
)
|
||||
ON CONFLICT (external_id) DO UPDATE SET
|
||||
email = EXCLUDED.email,
|
||||
display_name = COALESCE(NULLIF(EXCLUDED.display_name, ''), app.app_user.display_name),
|
||||
|
|
@ -1349,6 +1416,10 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
WHEN $9::boolean IS NULL THEN app.app_user.admin_access
|
||||
ELSE EXCLUDED.admin_access
|
||||
END,
|
||||
learner_feedback_enabled = CASE
|
||||
WHEN $10::boolean IS NULL THEN app.app_user.learner_feedback_enabled
|
||||
ELSE EXCLUDED.learner_feedback_enabled
|
||||
END,
|
||||
cohort = COALESCE(EXCLUDED.cohort, app.app_user.cohort),
|
||||
affiliation = COALESCE(NULLIF(EXCLUDED.affiliation, ''), app.app_user.affiliation),
|
||||
is_active = CASE WHEN $7 THEN TRUE ELSE app.app_user.is_active END,
|
||||
|
|
@ -1367,6 +1438,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
display_name,
|
||||
role,
|
||||
admin_access,
|
||||
learner_feedback_enabled,
|
||||
account_status,
|
||||
cohort,
|
||||
affiliation,
|
||||
|
|
@ -1396,6 +1468,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
data.reactivate,
|
||||
desired_account_status,
|
||||
desired_admin_access,
|
||||
data.learner_feedback_enabled,
|
||||
)
|
||||
if row is None:
|
||||
_inactive_emails.add(normalized_email)
|
||||
|
|
@ -1438,6 +1511,7 @@ async def upsert_managed_user(data: ManagedUserUpsertInput) -> ManagedUser:
|
|||
cohort_ids=data.cohort_ids,
|
||||
user_id=fallback_uid,
|
||||
affiliation=data.affiliation,
|
||||
learner_feedback_enabled=data.learner_feedback_enabled,
|
||||
reactivate=data.reactivate,
|
||||
)
|
||||
)
|
||||
|
|
@ -1455,6 +1529,7 @@ async def get_managed_user(user_id: str) -> ManagedUser | None:
|
|||
display_name,
|
||||
role,
|
||||
admin_access,
|
||||
learner_feedback_enabled,
|
||||
account_status,
|
||||
cohort,
|
||||
affiliation,
|
||||
|
|
@ -1504,6 +1579,7 @@ async def get_managed_user_by_email(email: str) -> ManagedUser | None:
|
|||
display_name,
|
||||
role,
|
||||
admin_access,
|
||||
learner_feedback_enabled,
|
||||
account_status,
|
||||
cohort,
|
||||
affiliation,
|
||||
|
|
@ -1555,6 +1631,7 @@ async def list_managed_users() -> tuple[list[ManagedUser], bool]:
|
|||
display_name,
|
||||
role,
|
||||
admin_access,
|
||||
learner_feedback_enabled,
|
||||
account_status,
|
||||
cohort,
|
||||
affiliation,
|
||||
|
|
@ -1599,6 +1676,7 @@ async def update_managed_user(
|
|||
role = COALESCE($3, role),
|
||||
account_status = COALESCE($18, account_status),
|
||||
admin_access = COALESCE($19, admin_access),
|
||||
learner_feedback_enabled = COALESCE($20, learner_feedback_enabled),
|
||||
cohort = CASE WHEN $4 THEN $5 ELSE cohort END,
|
||||
affiliation = COALESCE($6, affiliation),
|
||||
legal_name = COALESCE($7, legal_name),
|
||||
|
|
@ -1623,6 +1701,7 @@ async def update_managed_user(
|
|||
display_name,
|
||||
role,
|
||||
admin_access,
|
||||
learner_feedback_enabled,
|
||||
account_status,
|
||||
cohort,
|
||||
affiliation,
|
||||
|
|
@ -1670,6 +1749,7 @@ async def update_managed_user(
|
|||
else None,
|
||||
patch.account_status,
|
||||
patch.admin_access,
|
||||
patch.learner_feedback_enabled,
|
||||
)
|
||||
if row is not None:
|
||||
next_user = _managed_user_from_row(row)
|
||||
|
|
@ -1698,6 +1778,9 @@ async def update_managed_user(
|
|||
account_status=patch.account_status
|
||||
if patch.account_status is not None
|
||||
else current.account_status,
|
||||
learner_feedback_enabled=patch.learner_feedback_enabled
|
||||
if patch.learner_feedback_enabled is not None
|
||||
else current.learner_feedback_enabled,
|
||||
cohort_ids=list(patch.cohort_ids)
|
||||
if patch.cohort_ids is not None
|
||||
else current.cohort_ids,
|
||||
|
|
@ -1934,6 +2017,7 @@ async def create_session(
|
|||
cohort_ids: list[str] | None = None,
|
||||
user_id: str | None = None,
|
||||
external_id: str | None = None,
|
||||
account_status: AccountStatus | None = None,
|
||||
) -> tuple[str, SessionUser]:
|
||||
raw_sid = secrets.token_urlsafe(32)
|
||||
normalized_email = _normalize_email(email)
|
||||
|
|
@ -1945,6 +2029,7 @@ async def create_session(
|
|||
cohort_ids=cohort_ids,
|
||||
user_id=user_id,
|
||||
external_id=external_id,
|
||||
account_status=account_status,
|
||||
reactivate=False,
|
||||
)
|
||||
)
|
||||
|
|
@ -1973,6 +2058,7 @@ async def create_session(
|
|||
consent_at=managed.consent_at,
|
||||
profile_completed_at=managed.profile_completed_at,
|
||||
expires_at=expires_at,
|
||||
learner_feedback_enabled=managed.learner_feedback_enabled,
|
||||
)
|
||||
sid_hash = _sid_hash(raw_sid)
|
||||
try:
|
||||
|
|
@ -2022,6 +2108,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
COALESCE(u.display_name, s.display_name, u.email) AS display_name,
|
||||
u.role,
|
||||
u.admin_access,
|
||||
u.learner_feedback_enabled,
|
||||
u.account_status,
|
||||
u.cohort,
|
||||
u.consent_at,
|
||||
|
|
@ -2078,6 +2165,9 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
consent_at=_optional_ts(row["consent_at"]),
|
||||
profile_completed_at=_optional_ts(row["profile_completed_at"]),
|
||||
expires_at=_ts(row["expires_at"]),
|
||||
learner_feedback_enabled=bool(
|
||||
_row_value(row, "learner_feedback_enabled", True)
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("browser session")
|
||||
|
|
@ -2103,6 +2193,7 @@ async def get_session(raw_sid: str | None) -> SessionUser | None:
|
|||
user.cohort_ids = list(managed.cohort_ids)
|
||||
user.consent_at = managed.consent_at
|
||||
user.profile_completed_at = managed.profile_completed_at
|
||||
user.learner_feedback_enabled = managed.learner_feedback_enabled
|
||||
return user
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -392,6 +392,8 @@ class Settings(BaseSettings):
|
|||
default="https://api-vignette.chanpaca.net/auth/callback",
|
||||
validation_alias="OAUTH_REDIRECT_URI",
|
||||
)
|
||||
# 로컬 dev-login과 SAML 조직 정책용 allowlist다. Google OIDC는 provider가
|
||||
# 검증한 이메일이면 도메인·사전등록 여부와 무관하게 로그인시킨다.
|
||||
auth_allowed_email_domains: list[str] = Field(
|
||||
default=["hs.ac.kr", "twentyoz.kr"],
|
||||
validation_alias="AUTH_ALLOWED_EMAIL_DOMAINS",
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ class Principal:
|
|||
display_name: str = "",
|
||||
consent_at: float | None = None,
|
||||
profile_completed_at: float | None = None,
|
||||
learner_feedback_enabled: bool = True,
|
||||
) -> None:
|
||||
self.user_id = user_id
|
||||
self.role = role
|
||||
|
|
@ -52,6 +53,7 @@ class Principal:
|
|||
self.display_name = display_name
|
||||
self.consent_at = consent_at
|
||||
self.profile_completed_at = profile_completed_at
|
||||
self.learner_feedback_enabled = learner_feedback_enabled
|
||||
|
||||
def can_access_role(self, role: Role) -> bool:
|
||||
if self.role == role:
|
||||
|
|
@ -76,6 +78,7 @@ class Principal:
|
|||
display_name=self.display_name,
|
||||
consent_at=self.consent_at,
|
||||
profile_completed_at=self.profile_completed_at,
|
||||
learner_feedback_enabled=self.learner_feedback_enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -122,6 +125,7 @@ async def get_current_principal(
|
|||
display_name=session.display_name,
|
||||
consent_at=session.consent_at,
|
||||
profile_completed_at=session.profile_completed_at,
|
||||
learner_feedback_enabled=session.learner_feedback_enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -139,24 +139,38 @@ class EngineClient:
|
|||
async def health(self) -> bool:
|
||||
return bool((await self.health_detail()).get("ok"))
|
||||
|
||||
async def health_detail(self) -> dict[str, Any]:
|
||||
async def _provider_health_detail(
|
||||
self,
|
||||
*,
|
||||
provider: EngineProvider,
|
||||
model: str | None = None,
|
||||
reasoning_effort: ReasoningEffort | None = None,
|
||||
) -> dict[str, Any]:
|
||||
try:
|
||||
params: dict[str, str] = {"provider": self.engine_mode}
|
||||
if self.default_model:
|
||||
params["model"] = self.default_model
|
||||
if self.default_reasoning_effort:
|
||||
params["reasoning_effort"] = self.default_reasoning_effort
|
||||
params: dict[str, str] = {"provider": provider}
|
||||
if model:
|
||||
params["model"] = model
|
||||
if reasoning_effort:
|
||||
params["reasoning_effort"] = reasoning_effort
|
||||
r = await self.client.get("/ready", params=params)
|
||||
if r.status_code == 404:
|
||||
live = await self.client.get("/health")
|
||||
return {
|
||||
"ok": live.status_code == 200,
|
||||
"detail": "gateway liveness only; readiness endpoint unavailable",
|
||||
# 게이트웨이 프로세스가 살아 있다는 사실만으로 특정 provider가
|
||||
# 응답을 만들 수 있다고 판정하면 회기 화면이 거짓 GREEN이 된다.
|
||||
"ok": False,
|
||||
"detail": (
|
||||
"게이트웨이는 응답하지만 공급자 준비상태 엔드포인트를 사용할 수 없음"
|
||||
if live.status_code == 200
|
||||
else "게이트웨이와 공급자 준비상태를 확인할 수 없음"
|
||||
),
|
||||
"status_code": live.status_code,
|
||||
"cached": False,
|
||||
}
|
||||
payload: dict[str, Any] = {}
|
||||
try:
|
||||
payload = r.json()
|
||||
decoded = r.json()
|
||||
payload = decoded if isinstance(decoded, dict) else {}
|
||||
except ValueError:
|
||||
payload = {}
|
||||
return {
|
||||
|
|
@ -170,6 +184,60 @@ class EngineClient:
|
|||
"ok": False,
|
||||
"detail": f"engine readiness transport error: {exc}",
|
||||
"status_code": None,
|
||||
"cached": False,
|
||||
}
|
||||
|
||||
async def health_detail(self) -> dict[str, Any]:
|
||||
"""관리자 기본 lane과 실시간 내담자 lane의 준비상태를 함께 반환한다.
|
||||
|
||||
내담자 턴은 ``live_client_provider``로, 평가·리뷰는 ``engine_mode``로
|
||||
분리될 수 있으므로 기본 공급자 하나만 확인해서는 실제 응답 가능 여부를 알 수 없다.
|
||||
"""
|
||||
|
||||
default_detail = await self._provider_health_detail(
|
||||
provider=self.engine_mode,
|
||||
model=self.default_model,
|
||||
reasoning_effort=self.default_reasoning_effort,
|
||||
)
|
||||
live_provider = self.live_client_provider or self.engine_mode
|
||||
if live_provider == self.engine_mode:
|
||||
live_detail = dict(default_detail)
|
||||
else:
|
||||
# 관리자 기본 model/reasoning 값은 engine_mode 소속이므로 별도 client
|
||||
# provider readiness에는 넘기지 않는다(_payload 계약과 동일).
|
||||
live_detail = await self._provider_health_detail(provider=live_provider)
|
||||
|
||||
default_ok = bool(default_detail.get("ok"))
|
||||
live_ok = bool(live_detail.get("ok"))
|
||||
if not default_ok:
|
||||
detail = (
|
||||
f"기본 공급자 {self.engine_mode}: "
|
||||
f"{default_detail.get('detail') or 'readiness failed'}"
|
||||
)
|
||||
status_code = default_detail.get("status_code")
|
||||
elif not live_ok:
|
||||
detail = (
|
||||
f"실시간 내담자 공급자 {live_provider}: "
|
||||
f"{live_detail.get('detail') or 'readiness failed'}"
|
||||
)
|
||||
status_code = live_detail.get("status_code")
|
||||
else:
|
||||
detail = str(live_detail.get("detail") or default_detail.get("detail") or "ready")
|
||||
status_code = live_detail.get("status_code")
|
||||
|
||||
return {
|
||||
"ok": default_ok and live_ok,
|
||||
"detail": detail,
|
||||
"status_code": status_code,
|
||||
"cached": bool(default_detail.get("cached")) and bool(live_detail.get("cached")),
|
||||
"default_engine": {
|
||||
"provider": self.engine_mode,
|
||||
**default_detail,
|
||||
},
|
||||
"live_client_engine": {
|
||||
"provider": live_provider,
|
||||
**live_detail,
|
||||
},
|
||||
}
|
||||
|
||||
async def capabilities(
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||
from fastapi.staticfiles import StaticFiles
|
||||
|
||||
from . import __version__
|
||||
from .access_logging import install_uvicorn_access_log_redaction
|
||||
from .auth_sessions import ensure_runtime_tables
|
||||
from .config import settings
|
||||
from .db import acquire, close_pool, healthcheck, init_pool
|
||||
|
|
@ -46,6 +47,7 @@ from .routes import multimodal_alliance as multimodal_alliance_routes
|
|||
from .routes import measurements as measurement_routes
|
||||
from .routes import outcome_trajectories as outcome_trajectory_routes
|
||||
from .routes import personas as persona_routes
|
||||
from .routes import protocols as protocol_routes
|
||||
from .routes import rupture_repairs as rupture_repair_routes
|
||||
from .routes import supervision_research as supervision_research_routes
|
||||
from .routes import sessions as session_routes
|
||||
|
|
@ -54,6 +56,7 @@ from .routes import teacher as teacher_routes
|
|||
from .routes import users as user_routes
|
||||
from .routes import voice as voice_routes
|
||||
from .services.notifications import ensure_notification_tables
|
||||
from .services.protocol_registry import ensure_protocol_tables
|
||||
from .services import (
|
||||
alliance_measurement,
|
||||
continuous_improvement_producer,
|
||||
|
|
@ -63,6 +66,7 @@ from .services.voice import voice_service
|
|||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
install_uvicorn_access_log_redaction()
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
|
|
@ -84,6 +88,7 @@ async def lifespan(app: FastAPI):
|
|||
await ensure_runtime_tables()
|
||||
await ensure_review_tables()
|
||||
await ensure_notification_tables()
|
||||
await ensure_protocol_tables()
|
||||
async with acquire(role="admin") as conn:
|
||||
measurement_schema_ready = await schema_contract_ready(
|
||||
conn,
|
||||
|
|
@ -251,6 +256,7 @@ app.include_router(calibration_transfer_routes.router)
|
|||
app.include_router(continuous_improvement_routes.router)
|
||||
app.include_router(client_diagnostics_routes.router)
|
||||
app.include_router(admin_routes.router)
|
||||
app.include_router(protocol_routes.router)
|
||||
app.include_router(persona_routes.router)
|
||||
app.include_router(session_routes.router)
|
||||
app.include_router(measurement_routes.router)
|
||||
|
|
@ -281,5 +287,10 @@ async def health() -> dict[str, object]:
|
|||
"db": db_ok,
|
||||
"engine": engine_ok,
|
||||
"engine_detail": engine.get("detail"),
|
||||
"engine_mode": settings.engine_mode,
|
||||
"engine_mode": engine_client.engine_mode,
|
||||
"live_client_provider": (
|
||||
engine_client.live_client_provider or engine_client.engine_mode
|
||||
),
|
||||
"default_engine": engine.get("default_engine"),
|
||||
"live_client_engine": engine.get("live_client_engine"),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -144,6 +144,16 @@ def _first_text_value(data: dict[str, Any]) -> str:
|
|||
return ""
|
||||
|
||||
|
||||
def _presenting_summary(data: dict[str, Any]) -> str:
|
||||
"""Return the authored presenting complaint, independent of JSON key order."""
|
||||
|
||||
for key in ("complaint", "주호소", "presenting_complaint", "chief_complaint"):
|
||||
value = data.get(key)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return _first_text_value(data)
|
||||
|
||||
|
||||
def persona_summary(entry: CatalogPersona) -> PersonaSummary:
|
||||
card = entry.card
|
||||
return PersonaSummary(
|
||||
|
|
@ -155,7 +165,7 @@ def persona_summary(entry: CatalogPersona) -> PersonaSummary:
|
|||
difficulty=card.difficulty,
|
||||
theory_target=card.theory_target,
|
||||
demographics=card.demographics,
|
||||
presenting_summary=_first_text_value(card.presenting),
|
||||
presenting_summary=_presenting_summary(card.presenting),
|
||||
source=entry.source,
|
||||
degraded=entry.degraded,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -344,6 +344,7 @@ class AdminUserResponse(BaseModel):
|
|||
display_name: str
|
||||
role: RoleName
|
||||
admin_access: bool
|
||||
learner_feedback_enabled: bool
|
||||
super_admin: bool = False
|
||||
account_status: AccountStatus
|
||||
cohort_ids: list[str]
|
||||
|
|
@ -364,6 +365,7 @@ class AdminUserPatch(BaseModel):
|
|||
display_name: str | None = Field(default=None, min_length=1, max_length=80)
|
||||
role: RoleName | None = None
|
||||
admin_access: bool | None = None
|
||||
learner_feedback_enabled: bool | None = None
|
||||
account_status: AccountStatus | None = None
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
cohort_ids: list[str] | None = None
|
||||
|
|
@ -1182,11 +1184,18 @@ def _usage_from_runtime_store(window_days: int) -> AdminUsageResponse:
|
|||
|
||||
|
||||
class AdminUserCreate(BaseModel):
|
||||
email: str = Field(..., min_length=3, max_length=254)
|
||||
email: str = Field(
|
||||
...,
|
||||
min_length=3,
|
||||
max_length=254,
|
||||
pattern=r"^[^@\s]+@[^@\s]+\.[^@\s]+$",
|
||||
)
|
||||
display_name: str = Field(..., min_length=1, max_length=80)
|
||||
role: RoleName = "learner"
|
||||
admin_access: bool = False
|
||||
account_status: AccountStatus = "approved"
|
||||
learner_feedback_enabled: bool = True
|
||||
# 외부 연구참여자는 exact-email 사전등록 뒤 별도 승인을 거치게 한다.
|
||||
account_status: Literal["pending"] = "pending"
|
||||
affiliation: str | None = Field(default=None, max_length=120)
|
||||
cohort_ids: list[str] = Field(default_factory=list)
|
||||
|
||||
|
|
@ -1574,6 +1583,7 @@ async def _admin_user_response(user, *, durable: bool) -> AdminUserResponse:
|
|||
display_name=user.display_name,
|
||||
role=user.role,
|
||||
admin_access=effective_admin_access,
|
||||
learner_feedback_enabled=user.learner_feedback_enabled,
|
||||
super_admin=is_super_admin_email(user.email),
|
||||
account_status=user.account_status,
|
||||
cohort_ids=user.cohort_ids,
|
||||
|
|
@ -2171,6 +2181,7 @@ async def create_user(
|
|||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
admin_access=body.admin_access,
|
||||
learner_feedback_enabled=body.learner_feedback_enabled,
|
||||
account_status=body.account_status,
|
||||
affiliation=body.affiliation,
|
||||
cohort_ids=body.cohort_ids,
|
||||
|
|
@ -2207,6 +2218,7 @@ async def patch_user(
|
|||
display_name=body.display_name,
|
||||
role=body.role,
|
||||
admin_access=body.admin_access,
|
||||
learner_feedback_enabled=body.learner_feedback_enabled,
|
||||
account_status=body.account_status,
|
||||
affiliation=body.affiliation,
|
||||
cohort_ids=body.cohort_ids,
|
||||
|
|
|
|||
|
|
@ -221,6 +221,25 @@ def validate_google_identity_domain(
|
|||
return normalized_email
|
||||
|
||||
|
||||
def validate_google_identity(
|
||||
*,
|
||||
email: str | None,
|
||||
email_verified: bool,
|
||||
) -> str:
|
||||
"""Accept every Google account whose email claim is present and verified.
|
||||
|
||||
Google has already validated the account before issuing the ID token. The
|
||||
application deliberately does not impose an email-domain or pre-registration
|
||||
gate on top of that provider identity.
|
||||
"""
|
||||
normalized_email = _normalize_email(email)
|
||||
if not normalized_email or not _email_domain(normalized_email):
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email claim is required")
|
||||
if not email_verified:
|
||||
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="email is not verified")
|
||||
return normalized_email
|
||||
|
||||
|
||||
async def validate_login_identity_email(
|
||||
*,
|
||||
email: str | None,
|
||||
|
|
@ -774,7 +793,10 @@ async def auth_config(request: Request) -> AuthConfigResponse:
|
|||
google_oauth_configured=google_ready,
|
||||
saml_configured=saml_ready,
|
||||
providers=_auth_provider_statuses(),
|
||||
allowed_email_domains=sorted(allowed_email_domains()),
|
||||
# Google OIDC accepts every provider-verified email. Keep the legacy
|
||||
# setting for dev-login/SAML policy, but do not advertise it as a Google
|
||||
# restriction to the browser.
|
||||
allowed_email_domains=[],
|
||||
redirect_uri=settings.oauth_redirect_uri,
|
||||
dev_login_enabled=_dev_login_available(request),
|
||||
)
|
||||
|
|
@ -937,19 +959,18 @@ async def callback(
|
|||
return _oauth_callback_error("issuer_mismatch", request)
|
||||
|
||||
try:
|
||||
email, managed_user = await validate_login_identity_email(
|
||||
email = validate_google_identity(
|
||||
email=claims.get("email"),
|
||||
email_verified=claims.get("email_verified") in {True, "true", "True", "1", 1},
|
||||
hosted_domain=claims.get("hd"),
|
||||
)
|
||||
except HTTPException:
|
||||
_log_oauth_callback_failure(
|
||||
request,
|
||||
"domain_not_allowed",
|
||||
"id_token_invalid",
|
||||
email_domain=_email_domain(str(claims.get("email") or "")),
|
||||
hosted_domain=_normalize_domain(str(claims.get("hd") or "")),
|
||||
)
|
||||
return _oauth_callback_error("domain_not_allowed", request)
|
||||
return _oauth_callback_error("id_token_invalid", request)
|
||||
managed_user = await get_managed_user_by_email(email)
|
||||
role = _role_for_managed_user(managed_user, _role_for_email(email))
|
||||
display_name = str(claims.get("name") or email)
|
||||
cohort_ids = _cohort_ids_for_managed_user(
|
||||
|
|
@ -967,6 +988,7 @@ async def callback(
|
|||
role=role.value,
|
||||
cohort_ids=cohort_ids,
|
||||
external_id=external_id,
|
||||
account_status="approved",
|
||||
)
|
||||
except InactiveUserError:
|
||||
_log_oauth_callback_failure(
|
||||
|
|
|
|||
|
|
@ -24,7 +24,11 @@ from ..contracts.calibration_transfer import (
|
|||
)
|
||||
from ..config import Settings, get_settings
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import calibration_transfer_store, session_learning_producer
|
||||
from ..services import (
|
||||
calibration_transfer_store,
|
||||
feedback_policy,
|
||||
session_learning_producer,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(tags=["calibration-transfer"])
|
||||
|
|
@ -337,8 +341,8 @@ class ActualTransferExecutionRequest(BaseModel):
|
|||
|
||||
|
||||
class ActualTransferExecutionResponse(BaseModel):
|
||||
execution: ActualTransferExecution
|
||||
assessment: ActualTransferAssessment
|
||||
execution: ActualTransferExecution | None = None
|
||||
assessment: ActualTransferAssessment | None = None
|
||||
idempotent_replay: bool
|
||||
|
||||
|
||||
|
|
@ -532,6 +536,22 @@ class CalibrationTransferReadModelResponse(BaseModel):
|
|||
)
|
||||
|
||||
|
||||
def _learner_input_only_payload(payload: dict[str, Any]) -> dict[str, Any]:
|
||||
"""자기예측 원문만 남기고 내부·교수자·AI 파생 판정을 제거한다."""
|
||||
|
||||
redacted = dict(payload)
|
||||
redacted["prediction_histories"] = [
|
||||
{**dict(history), "external_observation": None}
|
||||
for history in payload.get("prediction_histories", [])
|
||||
]
|
||||
redacted["calibration_assessments"] = []
|
||||
redacted["transfer_suites"] = []
|
||||
redacted["teacher_reviews"] = []
|
||||
redacted["actual_executions"] = []
|
||||
redacted["actual_transfer_assessments"] = []
|
||||
return redacted
|
||||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(
|
||||
exc, calibration_transfer_store.CalibrationTransferNotFoundError
|
||||
|
|
@ -673,12 +693,20 @@ async def create_actual_transfer_execution(
|
|||
body: ActualTransferExecutionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> ActualTransferExecutionResponse:
|
||||
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
|
||||
body.practice_session_id,
|
||||
principal,
|
||||
)
|
||||
try:
|
||||
payload = await calibration_transfer_store.append_actual_transfer_execution(
|
||||
principal=principal, **body.model_dump()
|
||||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
if not expose_feedback:
|
||||
return ActualTransferExecutionResponse(
|
||||
idempotent_replay=bool(payload.get("idempotent_replay", False))
|
||||
)
|
||||
return ActualTransferExecutionResponse.model_validate(payload)
|
||||
|
||||
|
||||
|
|
@ -706,6 +734,7 @@ async def create_teacher_review(
|
|||
)
|
||||
async def get_my_calibration_transfer(
|
||||
principal: LearnerPrincipal,
|
||||
session_id: UUID | None = None,
|
||||
) -> CalibrationTransferReadModelResponse:
|
||||
try:
|
||||
payload = await calibration_transfer_store.read_calibration_transfer(
|
||||
|
|
@ -713,6 +742,23 @@ async def get_my_calibration_transfer(
|
|||
)
|
||||
except _STORE_ERRORS as exc:
|
||||
raise _http_error(exc) from exc
|
||||
payload = dict(payload)
|
||||
snapshot_enabled = bool(
|
||||
payload.pop("_learner_feedback_snapshot_enabled", True)
|
||||
)
|
||||
expose_feedback = bool(
|
||||
principal.learner_feedback_enabled and snapshot_enabled
|
||||
)
|
||||
if session_id is not None:
|
||||
expose_feedback = bool(
|
||||
expose_feedback
|
||||
and await feedback_policy.can_expose_session_learner_feedback(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
)
|
||||
if not expose_feedback:
|
||||
payload = _learner_input_only_payload(payload)
|
||||
return CalibrationTransferReadModelResponse.model_validate(payload)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ from ..contracts.deliberate_practice import (
|
|||
PracticePrescription,
|
||||
)
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import deliberate_practice_store
|
||||
from ..services import deliberate_practice_store, feedback_policy
|
||||
|
||||
|
||||
router = APIRouter(tags=["deliberate-practice"])
|
||||
|
|
@ -248,6 +248,14 @@ class DeliberatePracticeReadModelResponse(BaseModel):
|
|||
|
||||
|
||||
def _http_error(exc: Exception) -> HTTPException:
|
||||
if isinstance(
|
||||
exc,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
):
|
||||
return HTTPException(
|
||||
status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
if isinstance(exc, deliberate_practice_store.DeliberatePracticeNotFoundError):
|
||||
return HTTPException(status.HTTP_404_NOT_FOUND, detail=str(exc))
|
||||
if isinstance(exc, deliberate_practice_store.DeliberatePracticeConflictError):
|
||||
|
|
@ -295,6 +303,7 @@ async def create_practice_attempt(
|
|||
body: PracticeAttemptSubmissionRequest,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PracticeAttemptSubmissionResponse:
|
||||
feedback_policy.require_principal_learner_feedback(principal)
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_learner_attempt_submission(
|
||||
principal=principal,
|
||||
|
|
@ -305,6 +314,7 @@ async def create_practice_attempt(
|
|||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
|
@ -321,6 +331,10 @@ async def observe_completed_practice_session(
|
|||
practice_session_id: UUID,
|
||||
principal: LearnerPrincipal,
|
||||
) -> PracticeAttemptSubmissionResponse:
|
||||
await feedback_policy.require_session_learner_feedback(
|
||||
practice_session_id,
|
||||
principal,
|
||||
)
|
||||
try:
|
||||
payload = await deliberate_practice_store.append_runtime_practice_session(
|
||||
principal=principal,
|
||||
|
|
@ -330,6 +344,7 @@ async def observe_completed_practice_session(
|
|||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
|
@ -368,6 +383,7 @@ async def correct_practice_attempt(
|
|||
async def get_my_deliberate_practice(
|
||||
principal: LearnerPrincipal,
|
||||
) -> DeliberatePracticeReadModelResponse:
|
||||
feedback_policy.require_principal_learner_feedback(principal)
|
||||
try:
|
||||
payload = await deliberate_practice_store.read_deliberate_practice(
|
||||
principal=principal
|
||||
|
|
@ -375,6 +391,7 @@ async def get_my_deliberate_practice(
|
|||
except (
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError,
|
||||
deliberate_practice_store.DeliberatePracticeConflictError,
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError,
|
||||
deliberate_practice_store.DeliberatePracticeStateError,
|
||||
) as exc:
|
||||
raise _http_error(exc) from exc
|
||||
|
|
|
|||
|
|
@ -123,7 +123,13 @@ async def reevaluate_session(
|
|||
엔진 장애는 503 으로 변환(평가는 비치명적이지만 트리거는 사용자 명시 요청이라 에러 노출).
|
||||
"""
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
enriched = enriched_masked_turns(sess.masked_turns())
|
||||
counselor_identity = getattr(sess, "learner_label", None)
|
||||
client_identity = getattr(sess.persona, "display_name", None)
|
||||
enriched = enriched_masked_turns(
|
||||
sess.masked_turns(),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
|
||||
# 누적 기법 코드 — DB 미가용이라 fast 결과가 없으면 빈 분포(deep LLM 정성 평가는 그대로 유효).
|
||||
technique_codes: list[str] = []
|
||||
|
|
@ -147,6 +153,8 @@ async def reevaluate_session(
|
|||
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
|
||||
stage=sess.state.stage.value,
|
||||
error=detail,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -161,6 +169,8 @@ async def reevaluate_session(
|
|||
session_id=session_id,
|
||||
learner_id=sess.learner_id,
|
||||
result=result,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
|
|||
|
|
@ -316,7 +316,8 @@ async def index_document(
|
|||
"""문서 인덱싱(관리자, RBAC ADMIN 강제). content_hash 증분 + 청크 임베딩 적재.
|
||||
|
||||
⚠️ 임베딩은 무거운 작업 → 본래 BackgroundTasks/배치 워커 위임 권장(202 Accepted).
|
||||
DSM verbatim 저작권(license C/D)은 source 등록 시점 external_llm_ok 가드 책임.
|
||||
source_id는 사전 등록된 kb.source만 허용하고, 라이선스와 프로토콜 active 상태는
|
||||
요청값이 아닌 DB 행으로 검증한다.
|
||||
모델 미가용 시 embedding NULL 폴백(BM25 만, degraded=True) — 크래시 X.
|
||||
"""
|
||||
req = rag.IndexRequest(
|
||||
|
|
@ -329,6 +330,7 @@ async def index_document(
|
|||
try:
|
||||
# 관리자 인덱싱은 RLS 미적용(쓰기 — kb 스키마 직접). role 주입 없이 acquire.
|
||||
async with acquire() as conn:
|
||||
await rag.validate_index_source(conn, body.source_id)
|
||||
result = await rag.index_document(conn, req)
|
||||
except rag.IndexPolicyViolation as e:
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(e)) from e
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ from ..contracts.measurement import (
|
|||
SourceKind,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import alliance_measurement
|
||||
from ..services import alliance_measurement, feedback_policy
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["measurements"])
|
||||
|
|
@ -168,6 +168,10 @@ async def get_alliance_pulses(
|
|||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> AlliancePulseListResponse:
|
||||
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
try:
|
||||
items = await alliance_measurement.list_alliance_pulses(
|
||||
principal=principal,
|
||||
|
|
@ -175,6 +179,8 @@ async def get_alliance_pulses(
|
|||
)
|
||||
except alliance_measurement.AlliancePulseNotFoundError as exc:
|
||||
raise _measurement_http_error(exc) from exc
|
||||
if not expose_feedback:
|
||||
items = [{**dict(item), "measurements": []} for item in items]
|
||||
return AlliancePulseListResponse.model_validate({"items": items})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ from ..contracts.multimodal_alliance import (
|
|||
ModalityAxisMeasurement,
|
||||
)
|
||||
from ..deps import AIView, Principal, Role, db_for_ai_view, require_role
|
||||
from ..services import multimodal_alliance_store
|
||||
from ..services import feedback_policy, multimodal_alliance_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["multimodal-alliance"])
|
||||
|
|
@ -521,7 +521,14 @@ async def sweep_multimodal_retention(
|
|||
async def get_multimodal_session_metadata(
|
||||
session_id: UUID,
|
||||
principal: HumanPrincipal,
|
||||
include_derived: bool = True,
|
||||
) -> MultimodalSessionMetadataResponse:
|
||||
expose_feedback = await feedback_policy.can_expose_session_learner_feedback(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
if include_derived and not expose_feedback:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await multimodal_alliance_store.read_session_metadata(
|
||||
principal=principal,
|
||||
|
|
@ -529,6 +536,15 @@ async def get_multimodal_session_metadata(
|
|||
)
|
||||
except multimodal_alliance_store.MultimodalAllianceError as exc:
|
||||
raise _http_error(exc) from exc
|
||||
if not include_derived:
|
||||
payload = {
|
||||
**payload,
|
||||
"timelines": [],
|
||||
"word_timestamps": [],
|
||||
"voice_events": [],
|
||||
"measurements": [],
|
||||
"fusion_decisions": [],
|
||||
}
|
||||
return MultimodalSessionMetadataResponse.model_validate(payload)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ from ..contracts.outcome_trajectory import (
|
|||
SyntheticExpectedDistribution,
|
||||
)
|
||||
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||
from ..services import outcome_trajectory_store
|
||||
from ..services import feedback_policy, outcome_trajectory_store
|
||||
|
||||
|
||||
router = APIRouter(prefix="/sessions", tags=["outcome-trajectories"])
|
||||
|
|
@ -190,6 +190,7 @@ async def get_outcome_trajectory(
|
|||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
|
|
@ -214,6 +215,7 @@ async def recompute_outcome_trajectory(
|
|||
body: OutcomeTrajectoryRecomputeRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> OutcomeTrajectoryResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await outcome_trajectory_store.read_outcome_trajectory(
|
||||
principal=principal,
|
||||
|
|
|
|||
220
apps/api/app/routes/protocols.py
Normal file
220
apps/api/app/routes/protocols.py
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
"""관리자 전용 상담 프로토콜 등록·활성화·퇴역 API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Annotated, Literal
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
from ..db import acquire
|
||||
from ..deps import Principal, Role, require_role
|
||||
from ..services import protocol_registry
|
||||
|
||||
router = APIRouter(prefix="/admin/protocols", tags=["admin-protocols"])
|
||||
AdminPrincipal = Annotated[Principal, Depends(require_role(Role.ADMIN))]
|
||||
|
||||
|
||||
class AdminProtocolCreate(BaseModel):
|
||||
title: str = Field(..., min_length=1, max_length=240)
|
||||
source: str = Field(..., min_length=1, max_length=1000)
|
||||
version: int = Field(default=1, ge=1, le=1_000_000)
|
||||
license: Literal["A", "B", "C", "D"]
|
||||
external_llm_ok: bool = False
|
||||
content: str = Field(..., min_length=1, max_length=500_000)
|
||||
|
||||
@field_validator("title", "source", "content")
|
||||
@classmethod
|
||||
def reject_blank_text(cls, value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("빈 값은 등록할 수 없습니다.")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def enforce_license_boundary(self) -> "AdminProtocolCreate":
|
||||
if self.license in {"C", "D"} and self.external_llm_ok:
|
||||
raise ValueError("라이선스 C/D는 외부 LLM 사용을 허용할 수 없습니다.")
|
||||
return self
|
||||
|
||||
|
||||
class AdminProtocolResponse(BaseModel):
|
||||
protocol_id: str
|
||||
source_id: str
|
||||
title: str
|
||||
source: str
|
||||
version: int
|
||||
license: Literal["A", "B", "C", "D"]
|
||||
external_llm_ok: bool
|
||||
content: str
|
||||
content_hash: str
|
||||
status: Literal["draft", "active", "retired"]
|
||||
registered_by: str
|
||||
registered_at: datetime
|
||||
activated_at: datetime | None = None
|
||||
retired_at: datetime | None = None
|
||||
|
||||
|
||||
class AdminProtocolListResponse(BaseModel):
|
||||
protocols: list[AdminProtocolResponse]
|
||||
total: int
|
||||
|
||||
|
||||
class AdminProtocolActivationResponse(BaseModel):
|
||||
protocol: AdminProtocolResponse
|
||||
chunks_indexed: int
|
||||
skipped_unchanged: bool
|
||||
embedded: bool
|
||||
degraded: bool
|
||||
|
||||
|
||||
def _response(record: protocol_registry.ProtocolRecord) -> AdminProtocolResponse:
|
||||
return AdminProtocolResponse(
|
||||
protocol_id=record.protocol_id,
|
||||
source_id=record.source_id,
|
||||
title=record.title,
|
||||
source=record.source,
|
||||
version=record.version,
|
||||
license=record.license,
|
||||
external_llm_ok=record.external_llm_ok,
|
||||
content=record.content,
|
||||
content_hash=record.content_hash,
|
||||
status=record.status,
|
||||
registered_by=record.registered_by,
|
||||
registered_at=record.registered_at,
|
||||
activated_at=record.activated_at,
|
||||
retired_at=record.retired_at,
|
||||
)
|
||||
|
||||
|
||||
def _raise_http(error: protocol_registry.ProtocolRegistryError) -> None:
|
||||
if isinstance(error, protocol_registry.ProtocolNotFound):
|
||||
raise HTTPException(status.HTTP_404_NOT_FOUND, detail=str(error)) from error
|
||||
if isinstance(error, protocol_registry.ProtocolTransitionConflict):
|
||||
raise HTTPException(status.HTTP_409_CONFLICT, detail=str(error)) from error
|
||||
if isinstance(error, protocol_registry.ProtocolPolicyViolation):
|
||||
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail=str(error)) from error
|
||||
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=str(error)) from error
|
||||
|
||||
|
||||
@router.get("", response_model=AdminProtocolListResponse)
|
||||
async def list_admin_protocols(
|
||||
principal: AdminPrincipal,
|
||||
status_filter: Annotated[
|
||||
Literal["draft", "active", "retired"] | None,
|
||||
Query(alias="status"),
|
||||
] = None,
|
||||
search: Annotated[str | None, Query(max_length=240)] = None,
|
||||
) -> AdminProtocolListResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
records = await protocol_registry.list_protocols(
|
||||
conn,
|
||||
status_filter=status_filter,
|
||||
search=search,
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return AdminProtocolListResponse(
|
||||
protocols=[_response(record) for record in records],
|
||||
total=len(records),
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=AdminProtocolResponse,
|
||||
status_code=status.HTTP_201_CREATED,
|
||||
)
|
||||
async def create_admin_protocol(
|
||||
body: AdminProtocolCreate,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminProtocolResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
record = await protocol_registry.create_protocol(
|
||||
conn,
|
||||
title=body.title,
|
||||
source=body.source,
|
||||
version=body.version,
|
||||
license_class=body.license,
|
||||
external_llm_ok=body.external_llm_ok,
|
||||
content=body.content,
|
||||
registered_by=principal.user_id,
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return _response(record)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{protocol_id}/activate",
|
||||
response_model=AdminProtocolActivationResponse,
|
||||
)
|
||||
async def activate_admin_protocol(
|
||||
protocol_id: UUID,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminProtocolActivationResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
async with conn.transaction():
|
||||
record, indexed = await protocol_registry.activate_protocol(
|
||||
conn,
|
||||
protocol_id=str(protocol_id),
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return AdminProtocolActivationResponse(
|
||||
protocol=_response(record),
|
||||
chunks_indexed=indexed.chunks_indexed,
|
||||
skipped_unchanged=indexed.skipped_unchanged,
|
||||
embedded=indexed.embedded,
|
||||
degraded=indexed.degraded,
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{protocol_id}/retire", response_model=AdminProtocolResponse)
|
||||
async def retire_admin_protocol(
|
||||
protocol_id: UUID,
|
||||
principal: AdminPrincipal,
|
||||
) -> AdminProtocolResponse:
|
||||
try:
|
||||
async with acquire(role="admin", user_id=principal.user_id) as conn:
|
||||
async with conn.transaction():
|
||||
record = await protocol_registry.retire_protocol(
|
||||
conn,
|
||||
protocol_id=str(protocol_id),
|
||||
)
|
||||
except protocol_registry.ProtocolRegistryError as error:
|
||||
_raise_http(error)
|
||||
except RuntimeError as error:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail=f"프로토콜 저장소를 사용할 수 없습니다: {error}",
|
||||
) from error
|
||||
return _response(record)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"AdminProtocolActivationResponse",
|
||||
"AdminProtocolCreate",
|
||||
"AdminProtocolListResponse",
|
||||
"AdminProtocolResponse",
|
||||
"router",
|
||||
]
|
||||
|
|
@ -15,7 +15,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida
|
|||
from ..contracts.rupture_repair import RuptureLifecycleState, RuptureType
|
||||
from ..config import Settings, get_settings
|
||||
from ..deps import AIView, CurrentPrincipal, db_for_ai_view
|
||||
from ..services import rupture_repair_store
|
||||
from ..services import feedback_policy, rupture_repair_store
|
||||
|
||||
|
||||
router = APIRouter(tags=["rupture-repairs"])
|
||||
|
|
@ -358,6 +358,7 @@ async def get_rupture_repairs(
|
|||
session_id: UUID,
|
||||
principal: CurrentPrincipal,
|
||||
) -> RuptureRepairReadModelResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
payload = await rupture_repair_store.read_rupture_repairs(
|
||||
principal=principal,
|
||||
|
|
@ -435,6 +436,7 @@ async def create_human_rupture_correction(
|
|||
body: HumanRuptureCorrectionRequest,
|
||||
principal: CurrentPrincipal,
|
||||
) -> HumanRuptureCorrectionResponse:
|
||||
await feedback_policy.require_session_learner_feedback(session_id, principal)
|
||||
try:
|
||||
observation_id = await rupture_repair_store.append_human_correction(
|
||||
principal=principal,
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ from ..runtime_policy import require_runtime_fallback_allowed
|
|||
from ..session_evaluation_input import enriched_masked_turns
|
||||
from ..services import (
|
||||
evaluator,
|
||||
feedback_policy,
|
||||
guardrail,
|
||||
live_coach,
|
||||
memory,
|
||||
|
|
@ -67,6 +68,7 @@ from ..session_read_model import (
|
|||
dashboard_growth as _dashboard_growth,
|
||||
dashboard_overview as _dashboard_overview,
|
||||
dashboard_persona_progress as _dashboard_persona_progress,
|
||||
dashboard_training_exposure as _dashboard_training_exposure,
|
||||
iso as _iso,
|
||||
learner_summary as _learner_summary,
|
||||
learner_visible_turns as _learner_visible_turns,
|
||||
|
|
@ -112,6 +114,8 @@ class SessionStartRequest(BaseModel):
|
|||
class SessionStartResponse(BaseModel):
|
||||
session_id: str
|
||||
case_id: str
|
||||
persona_id: str
|
||||
persona_version: int
|
||||
session_no: int
|
||||
stage: StageLabel
|
||||
effective_openness: float
|
||||
|
|
@ -122,6 +126,7 @@ class SessionStartResponse(BaseModel):
|
|||
# 시간 기반 회기 종료 계약(회의 P1): 프론트 타이머·10분 전 알람의 기준값.
|
||||
duration_limit_seconds: int = 0
|
||||
warning_before_end_seconds: int = 0
|
||||
learner_feedback_enabled: bool = True
|
||||
|
||||
|
||||
class TurnRequest(BaseModel):
|
||||
|
|
@ -308,6 +313,8 @@ async def _retrieve_live_coach_grounding(
|
|||
source_type=source_type or None,
|
||||
version=source_version or None,
|
||||
citation=citation or None,
|
||||
license_class=chunk.license_class,
|
||||
external_llm_ok=chunk.external_llm_ok,
|
||||
summary=body[:500],
|
||||
)
|
||||
)
|
||||
|
|
@ -525,6 +532,7 @@ async def _prepare_turn_context(
|
|||
card=sess.persona,
|
||||
state=sess.state,
|
||||
learner_text=learner_text,
|
||||
learner_identity=sess.learner_label,
|
||||
memory=orchestrator.TurnMemory(
|
||||
recall_summary=recall.recall_summary,
|
||||
pinned_facts=recall.pinned_facts,
|
||||
|
|
@ -908,7 +916,11 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
return
|
||||
|
||||
timeout_seconds = _session_evaluation_timeout_seconds()
|
||||
enriched = enriched_masked_turns(sess.masked_turns())
|
||||
enriched = enriched_masked_turns(
|
||||
sess.masked_turns(),
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
|
|
@ -928,6 +940,8 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
session_id=sess.session_id,
|
||||
learner_id=sess.learner_id,
|
||||
result=result,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -960,6 +974,8 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
scope="session_end",
|
||||
stage=_stage_label(sess.state.stage),
|
||||
error=message,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -978,6 +994,8 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
|||
scope="session_end",
|
||||
stage=_stage_label(sess.state.stage),
|
||||
error=exc,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
saved = await session_persistence.save_session_evaluation(write)
|
||||
if not saved:
|
||||
|
|
@ -1124,6 +1142,8 @@ async def _load_learner_sessions(
|
|||
|
||||
|
||||
async def _review_ready(sess: InProcSession, principal: Principal) -> bool:
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
return False
|
||||
turns = _learner_visible_turns(sess)
|
||||
if not sess.ended or not turns:
|
||||
return False
|
||||
|
|
@ -1173,6 +1193,9 @@ async def _session_archive_response(
|
|||
session=_learner_summary(
|
||||
sess,
|
||||
review_ready=await _review_ready(sess, principal),
|
||||
learner_feedback_enabled=(
|
||||
feedback_policy.effective_learner_feedback_enabled(sess, principal)
|
||||
),
|
||||
archived=archived,
|
||||
archived_at=archived_at,
|
||||
),
|
||||
|
|
@ -1194,6 +1217,12 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR
|
|||
_learner_summary(
|
||||
sess,
|
||||
review_ready=await _review_ready(sess, principal),
|
||||
learner_feedback_enabled=(
|
||||
feedback_policy.effective_learner_feedback_enabled(
|
||||
sess,
|
||||
principal,
|
||||
)
|
||||
),
|
||||
archived=archive_record is not None,
|
||||
archived_at=_iso(float(archived_at))
|
||||
if isinstance(archived_at, (int, float))
|
||||
|
|
@ -1213,10 +1242,22 @@ async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResp
|
|||
principal = _ensure_learner(principal)
|
||||
sessions, durable = await _load_learner_sessions(
|
||||
principal,
|
||||
include_turn_evaluation=True,
|
||||
include_turn_evaluation=principal.learner_feedback_enabled,
|
||||
)
|
||||
review_ready = await _review_ready_map(sessions, principal)
|
||||
archives = await _archive_map(sessions, principal)
|
||||
learner_feedback_enabled = {
|
||||
sess.session_id: feedback_policy.effective_learner_feedback_enabled(
|
||||
sess,
|
||||
principal,
|
||||
)
|
||||
for sess in sessions
|
||||
}
|
||||
feedback_sessions = [
|
||||
sess
|
||||
for sess in sessions
|
||||
if learner_feedback_enabled.get(sess.session_id, True)
|
||||
]
|
||||
visible_review_ready = {
|
||||
session_id: ready
|
||||
for session_id, ready in review_ready.items()
|
||||
|
|
@ -1229,10 +1270,15 @@ async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResp
|
|||
visible_review_ready=visible_review_ready,
|
||||
archived_sessions=len(archives),
|
||||
),
|
||||
growth=_dashboard_growth(sessions),
|
||||
persona_progress=_dashboard_persona_progress(sessions, visible_review_ready),
|
||||
growth=_dashboard_growth(feedback_sessions),
|
||||
persona_progress=_dashboard_persona_progress(
|
||||
sessions,
|
||||
visible_review_ready,
|
||||
learner_feedback_enabled,
|
||||
),
|
||||
training_exposure=_dashboard_training_exposure(sessions),
|
||||
achievements=_dashboard_achievements(sessions, visible_review_ready),
|
||||
recent_feedback=_dashboard_feedback(sessions),
|
||||
recent_feedback=_dashboard_feedback(feedback_sessions),
|
||||
message=(
|
||||
"실제 연습 기록을 기준으로 개인 학습 흐름을 표시합니다."
|
||||
if sessions
|
||||
|
|
@ -1253,7 +1299,13 @@ async def get_session_detail(
|
|||
principal,
|
||||
allow_ended=True,
|
||||
)
|
||||
return _session_detail(sess, review_ready=await _review_ready(sess, principal))
|
||||
return _session_detail(
|
||||
sess,
|
||||
review_ready=await _review_ready(sess, principal),
|
||||
learner_feedback_enabled=(
|
||||
feedback_policy.effective_learner_feedback_enabled(sess, principal)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{session_id}/archive", response_model=SessionArchiveResponse)
|
||||
|
|
@ -1355,6 +1407,8 @@ async def start_session(
|
|||
|
||||
carry_rapport = st.rapport_credit
|
||||
goal_stages = [str(stage) for stage in body.goal_stages]
|
||||
learner_feedback_enabled = principal.learner_feedback_enabled
|
||||
try:
|
||||
sess = await session_persistence.create_session(
|
||||
learner_id=principal.user_id,
|
||||
card=card,
|
||||
|
|
@ -1366,7 +1420,13 @@ async def start_session(
|
|||
persona_version=catalog_persona.version,
|
||||
case_id=case_context.case_id if case_context else None,
|
||||
goal_stages=goal_stages,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
)
|
||||
except session_persistence.SessionCreationPersistenceError as exc:
|
||||
raise HTTPException(
|
||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
detail="session_persistence_unavailable",
|
||||
) from exc
|
||||
degraded = catalog_persona.degraded or sess is None
|
||||
if sess is None:
|
||||
require_runtime_fallback_allowed("session creation")
|
||||
|
|
@ -1375,13 +1435,21 @@ async def start_session(
|
|||
persona=card,
|
||||
theory_mode=body.theory_mode,
|
||||
state=st,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
session_no=session_no,
|
||||
carry_rapport=carry_rapport,
|
||||
goal_stages=goal_stages,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
)
|
||||
else:
|
||||
store.put(sess)
|
||||
|
||||
# DB 재조회 전의 첫 턴과 runtime fallback에서도 인증된 학습자 표시명을
|
||||
# 역할 기반 비식별화에 사용할 수 있게 in-process 세션에만 보존한다.
|
||||
sess.learner_label = principal.display_name or principal.user_id
|
||||
store.put(sess)
|
||||
|
||||
# 즉시 빈/carry 회상으로 응답을 막지 않는다. RAG 회상·KB 단서(임베더 로드 수 초)는
|
||||
# 백그라운드 warm으로 캐시 — 회기 시작/턴 응답이 임베더 로드에 블로킹되지 않게(성능 회귀 방지).
|
||||
_RECALL_CACHE[sess.session_id] = recall
|
||||
|
|
@ -1390,6 +1458,8 @@ async def start_session(
|
|||
return SessionStartResponse(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
persona_id=catalog_persona.persona_id,
|
||||
persona_version=catalog_persona.version,
|
||||
session_no=sess.session_no,
|
||||
stage=_stage_label(st.stage),
|
||||
effective_openness=round(st.effective_openness, 4),
|
||||
|
|
@ -1399,6 +1469,7 @@ async def start_session(
|
|||
goal_stages=body.goal_stages,
|
||||
duration_limit_seconds=settings.session_duration_minutes * 60,
|
||||
warning_before_end_seconds=settings.session_warning_minutes * 60,
|
||||
learner_feedback_enabled=sess.learner_feedback_enabled,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -1413,6 +1484,13 @@ async def get_session_review(
|
|||
principal,
|
||||
include_turn_evaluation=True,
|
||||
)
|
||||
expose_learner_feedback = (
|
||||
feedback_policy.can_expose_principal_learner_feedback(
|
||||
sess,
|
||||
review_principal,
|
||||
)
|
||||
)
|
||||
if expose_learner_feedback:
|
||||
(
|
||||
evaluation_record,
|
||||
evaluation_durable,
|
||||
|
|
@ -1420,11 +1498,17 @@ async def get_session_review(
|
|||
session_id,
|
||||
review_principal,
|
||||
)
|
||||
else:
|
||||
evaluation_record, evaluation_durable = None, True
|
||||
saved_worksheet_payload, _ = await session_persistence.load_case_worksheet(
|
||||
session_id,
|
||||
review_principal,
|
||||
)
|
||||
include_teacher_review = review_principal.role in {Role.TEACHER, Role.ADMIN}
|
||||
learner_feedback_enabled = feedback_policy.effective_learner_feedback_enabled(
|
||||
sess,
|
||||
review_principal,
|
||||
)
|
||||
teacher_review_record = None
|
||||
if include_teacher_review:
|
||||
teacher_review_record, _ = await session_persistence.load_session_review_status(
|
||||
|
|
@ -1440,6 +1524,8 @@ async def get_session_review(
|
|||
saved_worksheet_payload=saved_worksheet_payload,
|
||||
include_teacher_review=include_teacher_review,
|
||||
teacher_review_record=teacher_review_record,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
expose_learner_feedback=expose_learner_feedback,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -1462,6 +1548,11 @@ async def create_session_share(
|
|||
raise HTTPException(
|
||||
status.HTTP_409_CONFLICT, detail="session must be ended before sharing"
|
||||
)
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
|
||||
review = await get_session_review(session_id, principal)
|
||||
token = secrets.token_urlsafe(32)
|
||||
|
|
@ -1606,7 +1697,12 @@ async def list_live_coach_history(
|
|||
) -> LiveCoachHistoryResponse:
|
||||
"""현재 회기에서 학습자에게 실제로 전달된 라이브 코칭 이력을 반환한다."""
|
||||
principal = _ensure_learner(principal)
|
||||
await _load_session_or_404(session_id, principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
events, durable = await session_persistence.list_live_coach_events(
|
||||
session_id, principal
|
||||
)
|
||||
|
|
@ -1641,6 +1737,11 @@ async def live_coach_turn(
|
|||
"""방금 완료된 턴에 대한 비차단 라이브 코칭을 반환한다."""
|
||||
principal = _ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal)
|
||||
if not feedback_policy.can_expose_principal_learner_feedback(sess, principal):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
quota, _ = await session_persistence.get_live_coach_quota(session_id, principal)
|
||||
if int(quota.get("remaining", 0)) <= 0:
|
||||
raise HTTPException(
|
||||
|
|
|
|||
|
|
@ -1458,6 +1458,7 @@ async def _prepare_voice_turn_context(
|
|||
card=sess.persona,
|
||||
state=sess.state,
|
||||
learner_text=learner_text,
|
||||
learner_identity=sess.learner_label,
|
||||
memory=orchestrator.TurnMemory(
|
||||
recall_summary=recall.recall_summary,
|
||||
pinned_facts=recall.pinned_facts,
|
||||
|
|
|
|||
|
|
@ -71,6 +71,14 @@ def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
|
|||
return default
|
||||
|
||||
|
||||
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""API 응답에서 학습자 피드백 정책 판정 전용 열을 제거한다."""
|
||||
|
||||
payload = dict(row)
|
||||
payload.pop("source_learner_feedback_enabled", None)
|
||||
return payload
|
||||
|
||||
|
||||
def _canonical_hash(payload: Mapping[str, Any]) -> str:
|
||||
serialized = json.dumps(
|
||||
payload,
|
||||
|
|
@ -1544,10 +1552,17 @@ async def read_calibration_transfer(
|
|||
histories = list(
|
||||
await conn.fetch(
|
||||
"""
|
||||
SELECT history_id, session_id, competency_id, practice_block_id,
|
||||
scenario_variant_id, phrase_family_id, created_at
|
||||
FROM app.calibration_prediction_history
|
||||
WHERE learner_id = $1 ORDER BY created_at, history_id
|
||||
SELECT history.history_id, history.session_id,
|
||||
history.competency_id, history.practice_block_id,
|
||||
history.scenario_variant_id, history.phrase_family_id,
|
||||
history.created_at,
|
||||
source_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.calibration_prediction_history history
|
||||
JOIN app.sessions source_session
|
||||
ON source_session.id = history.session_id
|
||||
WHERE history.learner_id = $1
|
||||
ORDER BY history.created_at, history.history_id
|
||||
""",
|
||||
target_learner_id,
|
||||
)
|
||||
|
|
@ -1615,10 +1630,13 @@ async def read_calibration_transfer(
|
|||
a.source_observation_ids, a.assessment_payload,
|
||||
a.model_run_id, a.instrument_id, a.instrument_version,
|
||||
a.evidence_turn_ids, a.created_at,
|
||||
p.prescription_id, p.prescription_payload
|
||||
p.prescription_id, p.prescription_payload,
|
||||
source_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.calibration_assessment_snapshot a
|
||||
JOIN app.calibration_metacognitive_prescription p
|
||||
ON p.assessment_snapshot_id = a.assessment_snapshot_id
|
||||
JOIN app.sessions source_session ON source_session.id = a.session_id
|
||||
WHERE a.learner_id = $1
|
||||
ORDER BY a.competency_id, a.snapshot_no
|
||||
""",
|
||||
|
|
@ -1628,12 +1646,18 @@ async def read_calibration_transfer(
|
|||
suites = list(
|
||||
await conn.fetch(
|
||||
"""
|
||||
SELECT transfer_suite_record_id, submission_id, suite_key,
|
||||
session_id, training_phrase_family_ids, model_run_id,
|
||||
instrument_id, instrument_version, data_classification,
|
||||
clinical_claim_allowed, created_at
|
||||
FROM app.calibration_transfer_suite
|
||||
WHERE learner_id = $1 ORDER BY created_at, transfer_suite_record_id
|
||||
SELECT suite.transfer_suite_record_id, suite.submission_id,
|
||||
suite.suite_key, suite.session_id,
|
||||
suite.training_phrase_family_ids, suite.model_run_id,
|
||||
suite.instrument_id, suite.instrument_version,
|
||||
suite.data_classification, suite.clinical_claim_allowed,
|
||||
suite.created_at,
|
||||
source_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.calibration_transfer_suite suite
|
||||
JOIN app.sessions source_session ON source_session.id = suite.session_id
|
||||
WHERE suite.learner_id = $1
|
||||
ORDER BY suite.created_at, suite.transfer_suite_record_id
|
||||
""",
|
||||
target_learner_id,
|
||||
)
|
||||
|
|
@ -1715,14 +1739,29 @@ async def read_calibration_transfer(
|
|||
actual_event_rows = list(
|
||||
await conn.fetch(
|
||||
"""
|
||||
SELECT *
|
||||
FROM app.calibration_transfer_execution_event
|
||||
WHERE learner_id = $1
|
||||
ORDER BY competency_id, created_at, execution_event_id
|
||||
SELECT execution.*,
|
||||
practice_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.calibration_transfer_execution_event execution
|
||||
JOIN app.sessions practice_session
|
||||
ON practice_session.id = execution.practice_session_id
|
||||
WHERE execution.learner_id = $1
|
||||
ORDER BY execution.competency_id, execution.created_at,
|
||||
execution.execution_event_id
|
||||
""",
|
||||
target_learner_id,
|
||||
)
|
||||
)
|
||||
learner_feedback_snapshot_enabled = all(
|
||||
bool(
|
||||
_value(
|
||||
item,
|
||||
"source_learner_feedback_enabled",
|
||||
True,
|
||||
)
|
||||
)
|
||||
for item in (*histories, *assessments, *suites, *actual_event_rows)
|
||||
)
|
||||
|
||||
revisions_by_history: dict[UUID, list[dict[str, Any]]] = {}
|
||||
for row in revisions:
|
||||
|
|
@ -1737,7 +1776,7 @@ async def read_calibration_transfer(
|
|||
}
|
||||
prediction_histories: list[dict[str, Any]] = []
|
||||
for row in histories:
|
||||
item = dict(row)
|
||||
item = _public_row(row)
|
||||
history_id = UUID(str(_value(row, "history_id")))
|
||||
item["revisions"] = revisions_by_history.get(history_id, [])
|
||||
item["lock"] = locks_by_history.get(history_id)
|
||||
|
|
@ -1760,7 +1799,7 @@ async def read_calibration_transfer(
|
|||
).append(dict(row))
|
||||
suite_payloads: list[dict[str, Any]] = []
|
||||
for row in suites:
|
||||
item = dict(row)
|
||||
item = _public_row(row)
|
||||
suite_id = UUID(str(_value(row, "transfer_suite_record_id")))
|
||||
item["trials"] = trials_by_suite.get(suite_id, [])
|
||||
item["assessments"] = assessments_by_suite.get(suite_id, [])
|
||||
|
|
@ -1774,8 +1813,9 @@ async def read_calibration_transfer(
|
|||
"learner_id": target_learner_id,
|
||||
"requested_view": requested_view,
|
||||
"clinical_claim_allowed": False,
|
||||
"_learner_feedback_snapshot_enabled": learner_feedback_snapshot_enabled,
|
||||
"prediction_histories": prediction_histories,
|
||||
"calibration_assessments": [dict(item) for item in assessments],
|
||||
"calibration_assessments": [_public_row(item) for item in assessments],
|
||||
"transfer_suites": suite_payloads,
|
||||
"teacher_reviews": [dict(item) for item in reviews],
|
||||
"actual_executions": [
|
||||
|
|
|
|||
|
|
@ -56,6 +56,10 @@ class DeliberatePracticeConflictError(RuntimeError):
|
|||
pass
|
||||
|
||||
|
||||
class DeliberatePracticeFeedbackDisabledError(PermissionError):
|
||||
"""처방/연습 원천 회기의 학습자 피드백 스냅샷이 비활성이다."""
|
||||
|
||||
|
||||
def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
|
||||
try:
|
||||
return row[key]
|
||||
|
|
@ -63,6 +67,14 @@ def _value(row: Mapping[str, Any], key: str, default: Any = None) -> Any:
|
|||
return default
|
||||
|
||||
|
||||
def _public_row(row: Mapping[str, Any]) -> dict[str, Any]:
|
||||
"""API 응답에서 정책 판정 전용 내부 열을 제거한다."""
|
||||
|
||||
payload = dict(row)
|
||||
payload.pop("source_learner_feedback_enabled", None)
|
||||
return payload
|
||||
|
||||
|
||||
def _canonical_hash(payload: Mapping[str, Any]) -> str:
|
||||
serialized = json.dumps(
|
||||
payload,
|
||||
|
|
@ -202,11 +214,15 @@ async def _latest_snapshot(
|
|||
) -> Mapping[str, Any] | None:
|
||||
return await conn.fetchrow(
|
||||
"""
|
||||
SELECT snapshot_id, session_id, snapshot_no, content_hash, graph_payload,
|
||||
evidence_turn_ids, created_at
|
||||
FROM app.competency_graph_snapshot
|
||||
WHERE learner_id = $1
|
||||
ORDER BY snapshot_no DESC
|
||||
SELECT snapshot.snapshot_id, snapshot.session_id, snapshot.snapshot_no,
|
||||
snapshot.content_hash, snapshot.graph_payload,
|
||||
snapshot.evidence_turn_ids, snapshot.created_at,
|
||||
source_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.competency_graph_snapshot snapshot
|
||||
JOIN app.sessions source_session ON source_session.id = snapshot.session_id
|
||||
WHERE snapshot.learner_id = $1
|
||||
ORDER BY snapshot.snapshot_no DESC
|
||||
LIMIT 1
|
||||
""",
|
||||
learner_id,
|
||||
|
|
@ -595,6 +611,37 @@ async def append_learner_attempt_submission(
|
|||
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
|
||||
f"practice-attempt:{submission_id}",
|
||||
)
|
||||
prescription_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT prescription.prescription_record_id,
|
||||
prescription.session_id,
|
||||
prescription.prescription_payload,
|
||||
prescription.created_at,
|
||||
source_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.practice_prescription prescription
|
||||
JOIN app.sessions source_session
|
||||
ON source_session.id = prescription.session_id
|
||||
WHERE prescription.learner_id = $1
|
||||
AND prescription.prescription_key = $2
|
||||
""",
|
||||
learner_id,
|
||||
prescription_id,
|
||||
)
|
||||
if prescription_row is None:
|
||||
raise DeliberatePracticeNotFoundError(
|
||||
"practice prescription not found or not visible"
|
||||
)
|
||||
if not bool(
|
||||
_value(
|
||||
prescription_row,
|
||||
"source_learner_feedback_enabled",
|
||||
True,
|
||||
)
|
||||
):
|
||||
raise DeliberatePracticeFeedbackDisabledError(
|
||||
"learner feedback was disabled for the prescription source session"
|
||||
)
|
||||
existing = await _existing_submission(
|
||||
conn,
|
||||
table="app.practice_episode_submission",
|
||||
|
|
@ -608,19 +655,6 @@ async def append_learner_attempt_submission(
|
|||
"SELECT pg_advisory_xact_lock(hashtextextended($1::text, 0))",
|
||||
f"practice-graph:{learner_id}",
|
||||
)
|
||||
prescription_row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT prescription_record_id, session_id, prescription_payload, created_at
|
||||
FROM app.practice_prescription
|
||||
WHERE learner_id = $1 AND prescription_key = $2
|
||||
""",
|
||||
learner_id,
|
||||
prescription_id,
|
||||
)
|
||||
if prescription_row is None:
|
||||
raise DeliberatePracticeNotFoundError(
|
||||
"practice prescription not found or not visible"
|
||||
)
|
||||
source_session_id = UUID(str(_value(prescription_row, "session_id")))
|
||||
session_id = practice_session_id or source_session_id
|
||||
practice_session = await _visible_session(conn, session_id)
|
||||
|
|
@ -1218,10 +1252,13 @@ async def read_deliberate_practice(
|
|||
p.activity_mode, p.scenario_variant_id, p.scenario_novelty,
|
||||
p.difficulty_level, p.prescription_payload, p.created_at,
|
||||
c.card_key, c.coach_claim, c.evidence_turn_ids, c.source_refs,
|
||||
c.uncertainty, c.counterevidence
|
||||
c.uncertainty, c.counterevidence,
|
||||
source_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.practice_prescription p
|
||||
JOIN app.practice_coaching_card c
|
||||
ON c.coaching_card_record_id = p.coaching_card_record_id
|
||||
JOIN app.sessions source_session ON source_session.id = p.session_id
|
||||
WHERE p.learner_id = $1
|
||||
ORDER BY p.created_at, p.prescription_record_id
|
||||
""",
|
||||
|
|
@ -1231,12 +1268,19 @@ async def read_deliberate_practice(
|
|||
episodes = list(
|
||||
await conn.fetch(
|
||||
"""
|
||||
SELECT episode_submission_id, episode_key, session_id,
|
||||
progress, mastery_allowed, mastery_blockers, uncertainty,
|
||||
evidence_turn_ids, counterevidence, assessment_payload, created_at
|
||||
FROM app.practice_episode_submission
|
||||
WHERE learner_id = $1
|
||||
ORDER BY created_at, episode_submission_id
|
||||
SELECT episode.episode_submission_id, episode.episode_key,
|
||||
episode.session_id, episode.progress,
|
||||
episode.mastery_allowed, episode.mastery_blockers,
|
||||
episode.uncertainty, episode.evidence_turn_ids,
|
||||
episode.counterevidence, episode.assessment_payload,
|
||||
episode.created_at,
|
||||
source_session.learner_feedback_enabled
|
||||
AS source_learner_feedback_enabled
|
||||
FROM app.practice_episode_submission episode
|
||||
JOIN app.sessions source_session
|
||||
ON source_session.id = episode.session_id
|
||||
WHERE episode.learner_id = $1
|
||||
ORDER BY episode.created_at, episode.episode_submission_id
|
||||
""",
|
||||
target_learner_id,
|
||||
)
|
||||
|
|
@ -1285,6 +1329,19 @@ async def read_deliberate_practice(
|
|||
else []
|
||||
)
|
||||
snapshot = await _latest_snapshot(conn, target_learner_id)
|
||||
if principal.role == Role.LEARNER and any(
|
||||
not bool(
|
||||
_value(
|
||||
item,
|
||||
"source_learner_feedback_enabled",
|
||||
True,
|
||||
)
|
||||
)
|
||||
for item in (*prescriptions, *episodes, *([snapshot] if snapshot else []))
|
||||
):
|
||||
raise DeliberatePracticeFeedbackDisabledError(
|
||||
"learner feedback was disabled for a practice source session"
|
||||
)
|
||||
decision = (
|
||||
await conn.fetchrow(
|
||||
"""
|
||||
|
|
@ -1313,7 +1370,7 @@ async def read_deliberate_practice(
|
|||
).append(payload)
|
||||
episode_payloads: list[dict[str, Any]] = []
|
||||
for row in episodes:
|
||||
payload = dict(row)
|
||||
payload = _public_row(row)
|
||||
payload["attempts"] = attempts_by_episode.get(
|
||||
UUID(str(_value(row, "episode_submission_id"))), []
|
||||
)
|
||||
|
|
@ -1321,7 +1378,7 @@ async def read_deliberate_practice(
|
|||
return {
|
||||
"learner_id": target_learner_id,
|
||||
"clinical_claim_allowed": False,
|
||||
"prescriptions": [dict(item) for item in prescriptions],
|
||||
"prescriptions": [_public_row(item) for item in prescriptions],
|
||||
"episodes": episode_payloads,
|
||||
"competency_graph": (
|
||||
_value(snapshot, "graph_payload") if snapshot is not None else None
|
||||
|
|
@ -1335,6 +1392,7 @@ async def read_deliberate_practice(
|
|||
|
||||
__all__ = [
|
||||
"DeliberatePracticeConflictError",
|
||||
"DeliberatePracticeFeedbackDisabledError",
|
||||
"DeliberatePracticeNotFoundError",
|
||||
"DeliberatePracticeStateError",
|
||||
"append_learner_attempt_submission",
|
||||
|
|
|
|||
|
|
@ -520,8 +520,11 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
|
|||
"""fast-loop 평가 프롬프트(L0 역할 + 후보 라벨 + 이번 턴 맥락)."""
|
||||
st = ctx.state_after or ctx.state_before
|
||||
theory = _theory_mode(ctx)
|
||||
client_reply_masked = guardrail.mask_synthetic_generated_pii(
|
||||
client_reply
|
||||
client_reply_masked = guardrail.mask_role_identities(
|
||||
client_reply,
|
||||
counselor_identity=ctx.counselor_identity,
|
||||
client_identity=ctx.client_identity,
|
||||
synthetic_generated=True,
|
||||
).text_masked
|
||||
recent = (
|
||||
"\n".join(
|
||||
|
|
|
|||
141
apps/api/app/services/feedback_policy.py
Normal file
141
apps/api/app/services/feedback_policy.py
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
"""계정별 학습자 AI 피드백 노출 정책의 단일 경계."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
|
||||
|
||||
LEARNER_FEEDBACK_DISABLED_DETAIL = "learner_feedback_disabled"
|
||||
|
||||
|
||||
class FeedbackPolicySession(Protocol):
|
||||
learner_feedback_enabled: bool
|
||||
|
||||
|
||||
class FeedbackPolicyPrincipal(Protocol):
|
||||
role: object
|
||||
learner_feedback_enabled: bool
|
||||
|
||||
|
||||
def _role_value(role: object) -> str:
|
||||
return str(getattr(role, "value", role))
|
||||
|
||||
|
||||
def learner_feedback_enabled(session: FeedbackPolicySession) -> bool:
|
||||
"""구 회기 객체는 호환성을 위해 피드백 허용으로 취급한다."""
|
||||
|
||||
return bool(getattr(session, "learner_feedback_enabled", True))
|
||||
|
||||
|
||||
def effective_learner_feedback_enabled(
|
||||
session: FeedbackPolicySession,
|
||||
principal: FeedbackPolicyPrincipal,
|
||||
) -> bool:
|
||||
"""학습자는 현재 계정과 회기 스냅샷이 모두 켜져야 피드백이 활성이다."""
|
||||
|
||||
snapshot_enabled = learner_feedback_enabled(session)
|
||||
if _role_value(principal.role) != "learner":
|
||||
return snapshot_enabled
|
||||
return snapshot_enabled and bool(
|
||||
getattr(principal, "learner_feedback_enabled", True)
|
||||
)
|
||||
|
||||
|
||||
def can_expose_principal_learner_feedback(
|
||||
session: FeedbackPolicySession,
|
||||
principal: FeedbackPolicyPrincipal,
|
||||
) -> bool:
|
||||
"""교수자·관리자는 유지하고 학습자에게 effective AND 정책을 적용한다."""
|
||||
|
||||
return _role_value(principal.role) != "learner" or (
|
||||
effective_learner_feedback_enabled(session, principal)
|
||||
)
|
||||
|
||||
|
||||
def can_expose_learner_feedback(
|
||||
session: FeedbackPolicySession,
|
||||
*,
|
||||
viewer_role: str,
|
||||
) -> bool:
|
||||
"""관리자·교수자 검토는 유지하고 학습자에게만 스냅샷 정책을 적용한다."""
|
||||
|
||||
return _role_value(viewer_role) != "learner" or learner_feedback_enabled(session)
|
||||
|
||||
|
||||
def require_learner_feedback(
|
||||
session: FeedbackPolicySession,
|
||||
*,
|
||||
viewer_role: object,
|
||||
) -> None:
|
||||
"""학습자에게만 회기 스냅샷 정책을 적용하고 파생 출력을 fail-closed 한다."""
|
||||
|
||||
if can_expose_learner_feedback(
|
||||
session,
|
||||
viewer_role=_role_value(viewer_role),
|
||||
):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
|
||||
|
||||
def require_principal_learner_feedback(principal: FeedbackPolicyPrincipal) -> None:
|
||||
"""회기 ID가 없는 학습자 전용 파생 원장은 계정 정책으로 차단한다."""
|
||||
|
||||
if _role_value(principal.role) != "learner" or bool(
|
||||
getattr(principal, "learner_feedback_enabled", True)
|
||||
):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
|
||||
|
||||
async def can_expose_session_learner_feedback(
|
||||
session_id: object,
|
||||
principal: FeedbackPolicyPrincipal,
|
||||
) -> bool:
|
||||
"""현재 계정과 생성 당시 회기 스냅샷을 모두 확인한다.
|
||||
|
||||
교수자·관리자 감독 화면은 그대로 유지한다. 학습자는 관리자가 현재 피드백을
|
||||
끈 경우 즉시 차단하고, 다시 켠 뒤에도 비활성 상태로 생성된 회기에서는 파생
|
||||
출력을 되살리지 않는다.
|
||||
"""
|
||||
|
||||
if _role_value(principal.role) != "learner":
|
||||
return True
|
||||
if not bool(getattr(principal, "learner_feedback_enabled", True)):
|
||||
return False
|
||||
|
||||
# 순환 import를 피하면서 세션 조회 경계와 동일한 RLS/런타임 폴백을 사용한다.
|
||||
from .. import session_persistence
|
||||
from ..store import store
|
||||
|
||||
session_key = str(session_id)
|
||||
session = await session_persistence.load_session(
|
||||
session_key,
|
||||
principal, # type: ignore[arg-type]
|
||||
allow_ended=True,
|
||||
)
|
||||
if session is None:
|
||||
session = store.get(session_key)
|
||||
if session is None:
|
||||
# 소유권·존재 오류는 각 도메인 저장소가 원래 계약대로 처리한다.
|
||||
return True
|
||||
return can_expose_principal_learner_feedback(session, principal)
|
||||
|
||||
|
||||
async def require_session_learner_feedback(
|
||||
session_id: object,
|
||||
principal: FeedbackPolicyPrincipal,
|
||||
) -> None:
|
||||
if await can_expose_session_learner_feedback(session_id, principal):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
|
|
@ -363,6 +363,89 @@ def mask_synthetic_generated_pii(text: str) -> MaskResult:
|
|||
)
|
||||
|
||||
|
||||
_IDENTITY_SEGMENT_RE = re.compile(r"\s*[·|,/]\s*", re.UNICODE)
|
||||
_IDENTITY_UUID_RE = re.compile(
|
||||
r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_IDENTITY_KO_PARTICLE_LOOKAHEAD = (
|
||||
r"(?:은|는|이|가|을|를|와|과|의|도|에게|께|랑|하고|님|씨)"
|
||||
)
|
||||
|
||||
|
||||
def _known_identity_variants(identity: str | None) -> list[str]:
|
||||
"""Return conservative display-name variants that are safe to role-tokenize."""
|
||||
|
||||
raw = str(identity or "").strip()
|
||||
if not raw or "@" in raw or _IDENTITY_UUID_RE.fullmatch(raw):
|
||||
return []
|
||||
first_segment = _IDENTITY_SEGMENT_RE.split(raw, maxsplit=1)[0].strip()
|
||||
first_segment = re.sub(r"\s*\(가명\)\s*$", "", first_segment).strip()
|
||||
variants: list[str] = []
|
||||
for candidate in (raw, first_segment):
|
||||
if candidate in variants:
|
||||
continue
|
||||
letters = re.sub(r"[^A-Za-z가-힣]", "", candidate)
|
||||
if len(letters) < 2:
|
||||
continue
|
||||
variants.append(candidate)
|
||||
return sorted(variants, key=len, reverse=True)
|
||||
|
||||
|
||||
def mask_role_identities(
|
||||
text: str,
|
||||
*,
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
synthetic_generated: bool = False,
|
||||
) -> MaskResult:
|
||||
"""Mask known session identities with role tokens, then apply the normal PII gate.
|
||||
|
||||
Only identities already owned by the authenticated session are role-tokenized.
|
||||
Unknown third-party names keep the generic ``[NAME]`` token so the UI cannot
|
||||
incorrectly present every person as the client.
|
||||
"""
|
||||
|
||||
role_values = (
|
||||
("ROLE_COUNSELOR", "[COUNSELOR]", counselor_identity),
|
||||
("ROLE_CLIENT", "[CLIENT]", client_identity),
|
||||
)
|
||||
redacted = text
|
||||
role_entities: list[str] = []
|
||||
claimed_variants: set[str] = set()
|
||||
for entity, placeholder, identity in role_values:
|
||||
for variant in _known_identity_variants(identity):
|
||||
normalized = variant.casefold()
|
||||
if normalized in claimed_variants:
|
||||
continue
|
||||
pattern = (
|
||||
rf"(?<![A-Za-z가-힣]){re.escape(variant)}"
|
||||
rf"(?=$|[^A-Za-z가-힣]|{_IDENTITY_KO_PARTICLE_LOOKAHEAD})"
|
||||
)
|
||||
replaced, count = re.subn(
|
||||
pattern,
|
||||
placeholder,
|
||||
redacted,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
if count:
|
||||
redacted = replaced
|
||||
role_entities.append(entity)
|
||||
claimed_variants.add(normalized)
|
||||
|
||||
masked = (
|
||||
mask_synthetic_generated_pii(redacted)
|
||||
if synthetic_generated
|
||||
else mask_pii(redacted)
|
||||
)
|
||||
return MaskResult(
|
||||
text_masked=masked.text_masked,
|
||||
entities=sorted(set(masked.entities + role_entities)),
|
||||
used_presidio=masked.used_presidio,
|
||||
used_ko_recognizer=masked.used_ko_recognizer,
|
||||
)
|
||||
|
||||
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
# 2. 위기 분류 (입력 — 실제위기 vs 페르소나 연기 구분, R8)
|
||||
# ════════════════════════════════════════════════════════════════════════════
|
||||
|
|
@ -438,6 +521,8 @@ _MEANS_TERMS = [
|
|||
"커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지",
|
||||
]
|
||||
_DISPLAY_PLACEHOLDER_LABELS = {
|
||||
"COUNSELOR": "상담자",
|
||||
"CLIENT": "내담자",
|
||||
"NAME": "그 이름",
|
||||
"ORG": "그 기관",
|
||||
"PHONE": "연락처",
|
||||
|
|
@ -449,7 +534,7 @@ _DISPLAY_PLACEHOLDER_LABELS = {
|
|||
"ADDR": "그 주소",
|
||||
}
|
||||
_DISPLAY_PLACEHOLDER_RE = re.compile(
|
||||
r"\[(?P<label>NAME|ORG|PHONE|EMAIL|RRN|NUMID|DATE|MONEY|ADDR)\]"
|
||||
r"\[(?P<label>COUNSELOR|CLIENT|NAME|ORG|PHONE|EMAIL|RRN|NUMID|DATE|MONEY|ADDR)\]"
|
||||
r"(?P<particle>[은는이가을를와과])?"
|
||||
)
|
||||
_DISPLAY_PLACEHOLDER_STREAM_TAIL = 16
|
||||
|
|
@ -625,6 +710,7 @@ __all__ = [
|
|||
"PiiEntitySpan",
|
||||
"set_ko_pii_recognizer",
|
||||
"mask_pii",
|
||||
"mask_role_identities",
|
||||
"mask_synthetic_generated_pii",
|
||||
"CrisisKind",
|
||||
"CrisisResult",
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ class LiveCoachGrounding(BaseModel):
|
|||
source_type: Optional[str] = None
|
||||
version: Optional[str] = None
|
||||
citation: Optional[str] = None
|
||||
license_class: Literal["A", "B", "C", "D"] | None = None
|
||||
external_llm_ok: bool = False
|
||||
summary: str
|
||||
|
||||
|
||||
|
|
@ -388,7 +390,8 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
|
|||
fallback: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
|
||||
for source_index, payload in enumerate(_local_source_payloads()):
|
||||
source = payload.get("source") or {}
|
||||
if source.get("external_llm_ok") is False:
|
||||
license_class = str(source.get("license_class") or "").strip().upper()
|
||||
if source.get("external_llm_ok") is not True or license_class not in {"A", "B"}:
|
||||
continue
|
||||
for chunk_index, chunk in enumerate(payload.get("chunks") or []):
|
||||
if not isinstance(chunk, dict):
|
||||
|
|
@ -404,6 +407,9 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
|
|||
scored.sort(key=lambda pair: (pair[0], pair[1]), reverse=True)
|
||||
out: list[LiveCoachGrounding] = []
|
||||
for _, _, source, chunk in scored[:limit]:
|
||||
source_license_class = str(source.get("license_class") or "").strip().upper()
|
||||
if source_license_class not in {"A", "B"}:
|
||||
continue
|
||||
out.append(
|
||||
LiveCoachGrounding(
|
||||
source_id=str(chunk.get("source_id") or source.get("source_id") or "live_coaching_source"),
|
||||
|
|
@ -413,12 +419,26 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
|
|||
source_type=str(chunk.get("source_type") or source.get("source_type") or "") or None,
|
||||
version=str(chunk.get("version") or source.get("version") or "") or None,
|
||||
citation=str(chunk.get("citation") or source.get("citation") or "") or None,
|
||||
license_class=source_license_class,
|
||||
external_llm_ok=source.get("external_llm_ok") is True,
|
||||
summary=str(chunk.get("summary") or ""),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def _external_safe_grounding(
|
||||
grounding: list[LiveCoachGrounding],
|
||||
) -> list[LiveCoachGrounding]:
|
||||
"""외부 엔진 요청에 명시적으로 허용된 A/B 근거만 전달한다."""
|
||||
|
||||
return [
|
||||
item
|
||||
for item in grounding
|
||||
if item.external_llm_ok is True and item.license_class in {"A", "B"}
|
||||
]
|
||||
|
||||
|
||||
def _source_refs(grounding: list[LiveCoachGrounding]) -> list[LiveCoachSource]:
|
||||
seen: set[tuple[str, str | None]] = set()
|
||||
refs: list[LiveCoachSource] = []
|
||||
|
|
@ -478,7 +498,30 @@ def _clip(value: Any, limit: int) -> Optional[str]:
|
|||
return text if len(text) <= limit else text[: limit - 1].rstrip() + "…"
|
||||
|
||||
|
||||
def _coerce_payload(payload: dict[str, Any], *, sources: list[LiveCoachSource], latency_ms: int) -> LiveCoachSuggestion:
|
||||
def _mask_generated_text(
|
||||
value: Any,
|
||||
*,
|
||||
client_identity: str | None,
|
||||
limit: int,
|
||||
) -> Optional[str]:
|
||||
text = str(value or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
masked = guardrail.mask_role_identities(
|
||||
text,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=True,
|
||||
).text_masked
|
||||
return _clip(masked, limit)
|
||||
|
||||
|
||||
def _coerce_payload(
|
||||
payload: dict[str, Any],
|
||||
*,
|
||||
sources: list[LiveCoachSource],
|
||||
latency_ms: int,
|
||||
client_identity: str | None = None,
|
||||
) -> LiveCoachSuggestion:
|
||||
tone = str(payload.get("tone") or "neutral")
|
||||
if tone not in ("pos", "warn", "neutral"):
|
||||
tone = "neutral"
|
||||
|
|
@ -500,11 +543,25 @@ def _coerce_payload(payload: dict[str, Any], *, sources: list[LiveCoachSource],
|
|||
status="ready",
|
||||
tone=tone, # type: ignore[arg-type]
|
||||
focus=focus, # type: ignore[arg-type]
|
||||
title=_clip(payload.get("title"), 32) or "다음 발화 조정",
|
||||
message=_clip(payload.get("message"), 120) or "지금은 내담자 말을 더 구체적으로 따라가는 편이 낫다.",
|
||||
next_utterance=_clip(payload.get("next_utterance"), 140),
|
||||
rationale=_clip(payload.get("rationale"), 180),
|
||||
safety_note=_clip(payload.get("safety_note"), 120),
|
||||
title=_mask_generated_text(
|
||||
payload.get("title"), client_identity=client_identity, limit=32
|
||||
)
|
||||
or "다음 발화 조정",
|
||||
message=_mask_generated_text(
|
||||
payload.get("message"), client_identity=client_identity, limit=120
|
||||
)
|
||||
or "지금은 내담자 말을 더 구체적으로 따라가는 편이 낫다.",
|
||||
next_utterance=_mask_generated_text(
|
||||
payload.get("next_utterance"),
|
||||
client_identity=client_identity,
|
||||
limit=140,
|
||||
),
|
||||
rationale=_mask_generated_text(
|
||||
payload.get("rationale"), client_identity=client_identity, limit=180
|
||||
),
|
||||
safety_note=_mask_generated_text(
|
||||
payload.get("safety_note"), client_identity=client_identity, limit=120
|
||||
),
|
||||
sources=sources,
|
||||
latency_ms=latency_ms,
|
||||
)
|
||||
|
|
@ -672,7 +729,8 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
|
|||
goals = ", ".join(item.goal_stages) if item.goal_stages else "(미지정)"
|
||||
prior = (
|
||||
"\n".join(
|
||||
f"- {entry.get('title', '')} (focus: {entry.get('focus', '')})"
|
||||
f"- {_mask_generated_text(entry.get('title'), client_identity=item.persona_name, limit=80) or ''} "
|
||||
f"(focus: {entry.get('focus', '')})"
|
||||
for entry in item.prior_coach[-2:]
|
||||
if entry.get("title")
|
||||
)
|
||||
|
|
@ -685,7 +743,7 @@ def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list
|
|||
)
|
||||
user = (
|
||||
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
|
||||
f"[내담자] {item.persona_name} ({item.persona_code})\n"
|
||||
f"[내담자] [CLIENT] ({item.persona_code})\n"
|
||||
f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n"
|
||||
f"[이번 회기 목표 단계] {goals} — 코칭은 목표 단계 작업에 정렬하고, 목표를 이미 이뤘다면 심화를 제안한다.\n"
|
||||
f"[직전 코칭]\n{prior}\n(같은 조언을 반복하지 말고 다음 단계를 제시한다)\n\n"
|
||||
|
|
@ -726,7 +784,7 @@ async def generate_live_coaching(
|
|||
) -> LiveCoachSuggestion:
|
||||
"""턴 직후 라이브 코칭을 생성한다. 실패해도 규칙 기반 제안으로 반환한다."""
|
||||
local_grounding = _local_reference_grounding(item)
|
||||
all_grounding = [*local_grounding, *(grounding or [])]
|
||||
all_grounding = _external_safe_grounding([*local_grounding, *(grounding or [])])
|
||||
crisis = guardrail.classify_crisis(item.learner_text)
|
||||
if crisis.escalate:
|
||||
return _fallback_suggestion(item, grounding=all_grounding, status="ready")
|
||||
|
|
@ -769,7 +827,12 @@ async def generate_live_coaching(
|
|||
grounding=all_grounding,
|
||||
reason="구조화 출력을 반환하지 않았다",
|
||||
)
|
||||
return _coerce_payload(payload, sources=_source_refs(all_grounding), latency_ms=latency_ms)
|
||||
return _coerce_payload(
|
||||
payload,
|
||||
sources=_source_refs(all_grounding),
|
||||
latency_ms=latency_ms,
|
||||
client_identity=item.persona_name,
|
||||
)
|
||||
except EngineError as exc:
|
||||
return _fallback_suggestion(item, grounding=all_grounding, reason=str(exc))
|
||||
except Exception as exc:
|
||||
|
|
|
|||
|
|
@ -79,6 +79,8 @@ class TurnContext:
|
|||
state_before: SessionState
|
||||
learner_text_raw: str
|
||||
learner_text_masked: str = ""
|
||||
counselor_identity: Optional[str] = None
|
||||
client_identity: Optional[str] = None
|
||||
crisis: Optional[guardrail.CrisisResult] = None
|
||||
state_after: Optional[SessionState] = None
|
||||
messages: list[EngineMessage] = field(default_factory=list)
|
||||
|
|
@ -133,6 +135,7 @@ def prepare_turn(
|
|||
card: PersonaCard,
|
||||
state: SessionState,
|
||||
learner_text: str,
|
||||
learner_identity: Optional[str] = None,
|
||||
memory: Optional[TurnMemory] = None,
|
||||
theory_mode: Optional[str] = None,
|
||||
eval_rapport_signal: Optional[float] = None,
|
||||
|
|
@ -146,23 +149,42 @@ def prepare_turn(
|
|||
state_machine 의 경량 휴리스틱으로 라포 신호를 추정한다.
|
||||
"""
|
||||
turn_memory = memory or TurnMemory()
|
||||
client_identity = card.display_name
|
||||
ctx = TurnContext(
|
||||
session_id=session_id,
|
||||
case_id=case_id,
|
||||
persona=card,
|
||||
state_before=state,
|
||||
learner_text_raw=learner_text,
|
||||
counselor_identity=learner_identity,
|
||||
client_identity=client_identity,
|
||||
memory=TurnMemory(
|
||||
recall_summary=_mask_optional_text(turn_memory.recall_summary),
|
||||
pinned_facts=_mask_text_list(turn_memory.pinned_facts),
|
||||
recent_turns=_mask_recent_turns(turn_memory.recent_turns),
|
||||
recall_summary=_mask_optional_text(
|
||||
turn_memory.recall_summary,
|
||||
counselor_identity=learner_identity,
|
||||
client_identity=client_identity,
|
||||
),
|
||||
pinned_facts=_mask_text_list(
|
||||
turn_memory.pinned_facts,
|
||||
counselor_identity=learner_identity,
|
||||
client_identity=client_identity,
|
||||
),
|
||||
recent_turns=_mask_recent_turns(
|
||||
turn_memory.recent_turns,
|
||||
counselor_identity=learner_identity,
|
||||
client_identity=client_identity,
|
||||
),
|
||||
kb_behavior_cues=list(turn_memory.kb_behavior_cues or []),
|
||||
),
|
||||
theory_mode=theory_mode,
|
||||
)
|
||||
|
||||
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
|
||||
mask = guardrail.mask_pii(learner_text)
|
||||
mask = guardrail.mask_role_identities(
|
||||
learner_text,
|
||||
counselor_identity=learner_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
ctx.learner_text_masked = mask.text_masked
|
||||
ctx.crisis = guardrail.classify_crisis(learner_text, speaker_is_persona_context=True)
|
||||
|
||||
|
|
@ -210,21 +232,51 @@ def prepare_turn(
|
|||
return ctx
|
||||
|
||||
|
||||
def _mask_optional_text(text: Optional[str]) -> Optional[str]:
|
||||
def _mask_optional_text(
|
||||
text: Optional[str],
|
||||
*,
|
||||
counselor_identity: Optional[str] = None,
|
||||
client_identity: Optional[str] = None,
|
||||
) -> Optional[str]:
|
||||
if text is None:
|
||||
return None
|
||||
return guardrail.mask_pii(text).text_masked
|
||||
return guardrail.mask_role_identities(
|
||||
text,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
).text_masked
|
||||
|
||||
|
||||
def _mask_text_list(values: Optional[list[str]]) -> list[str]:
|
||||
return [guardrail.mask_pii(value).text_masked for value in (values or [])]
|
||||
def _mask_text_list(
|
||||
values: Optional[list[str]],
|
||||
*,
|
||||
counselor_identity: Optional[str] = None,
|
||||
client_identity: Optional[str] = None,
|
||||
) -> list[str]:
|
||||
return [
|
||||
guardrail.mask_role_identities(
|
||||
value,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
).text_masked
|
||||
for value in (values or [])
|
||||
]
|
||||
|
||||
|
||||
def _mask_recent_turns(turns: Optional[list[dict[str, str]]]) -> list[dict[str, str]]:
|
||||
def _mask_recent_turns(
|
||||
turns: Optional[list[dict[str, str]]],
|
||||
*,
|
||||
counselor_identity: Optional[str] = None,
|
||||
client_identity: Optional[str] = None,
|
||||
) -> list[dict[str, str]]:
|
||||
masked: list[dict[str, str]] = []
|
||||
for turn in turns or []:
|
||||
item = dict(turn)
|
||||
item["text"] = guardrail.mask_pii(str(item.get("text", ""))).text_masked
|
||||
item["text"] = guardrail.mask_role_identities(
|
||||
str(item.get("text", "")),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
).text_masked
|
||||
masked.append(item)
|
||||
return masked
|
||||
|
||||
|
|
@ -427,6 +479,7 @@ async def run_turn_stream(
|
|||
flagged = False
|
||||
output_error: str | None = None
|
||||
stream_meta: dict[str, Any] = {}
|
||||
gateway_done = False
|
||||
previous_client_reply = _latest_client_reply(ctx.memory.recent_turns)
|
||||
if ctx.crisis is not None and ctx.crisis.escalate:
|
||||
flagged = True
|
||||
|
|
@ -471,6 +524,7 @@ async def run_turn_stream(
|
|||
payload = packet.payload
|
||||
if isinstance(payload, StreamDoneEvent):
|
||||
stream_meta = payload.model_dump()
|
||||
gateway_done = True
|
||||
break
|
||||
|
||||
payload = packet.payload
|
||||
|
|
@ -479,6 +533,12 @@ async def run_turn_stream(
|
|||
text_piece = payload.text
|
||||
accumulated += text_piece
|
||||
|
||||
if not gateway_done:
|
||||
# 토큰 일부 또는 빈 본문 뒤 연결이 끊겨도 성공 done을 합성하지 않는다.
|
||||
# 라우트는 이 error 이벤트를 전달하고 durable turn을 저장하지 않는다.
|
||||
yield StreamEvent("error", {"detail": "client_stream_incomplete"})
|
||||
return
|
||||
|
||||
scenario_leakage = rupture_scenario_director.contains_internal_scenario_leakage(
|
||||
accumulated
|
||||
)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ from dataclasses import dataclass, field
|
|||
from typing import TYPE_CHECKING, Any, Optional
|
||||
|
||||
from ..engine_client import EngineMessage
|
||||
from .guardrail import clamp_ideation
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .state_machine import OpennessParams
|
||||
|
|
@ -65,7 +66,11 @@ class PersonaCard:
|
|||
return float(self.resistance.get("decay_floor", 0.05))
|
||||
|
||||
def ideation_baseline(self) -> int:
|
||||
return int(self.affect_baseline.get("suicide_ideation_stage", 1))
|
||||
# DB에 들어온 과거/외부 카드가 0 또는 안전 상한 밖 값을 포함해도
|
||||
# session_state(1~5) 원장 쓰기와 R5 출력 상한(<=3)을 깨지 않게 한다.
|
||||
return clamp_ideation(
|
||||
int(self.affect_baseline.get("suicide_ideation_stage", 1)),
|
||||
)
|
||||
|
||||
def openness_params(self) -> "OpennessParams":
|
||||
"""init_state 입력용 openness 파라미터 묶음(base_resistance/unlock_rate/decay_floor/
|
||||
|
|
|
|||
517
apps/api/app/services/protocol_registry.py
Normal file
517
apps/api/app/services/protocol_registry.py
Normal file
|
|
@ -0,0 +1,517 @@
|
|||
"""관리자 프로토콜 등록·활성화·퇴역 수명주기.
|
||||
|
||||
초안 원문은 ``kb.protocol_registration`` 에만 머문다. 활성화 트랜잭션이
|
||||
``kb.source`` 등록과 기존 RAG 인덱싱을 모두 마친 뒤에만 status를 active로 바꾼다.
|
||||
검색 쪽은 레지스트리 행이 있는 source를 active 상태에서만 허용한다.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
import hashlib
|
||||
import re
|
||||
from typing import Any, Literal
|
||||
from uuid import uuid4
|
||||
|
||||
from . import rag
|
||||
|
||||
ProtocolStatus = Literal["draft", "active", "retired"]
|
||||
ProtocolLicense = Literal["A", "B", "C", "D"]
|
||||
|
||||
|
||||
class ProtocolRegistryError(Exception):
|
||||
"""프로토콜 레지스트리의 도메인 오류."""
|
||||
|
||||
|
||||
class ProtocolNotFound(ProtocolRegistryError):
|
||||
"""요청한 프로토콜이 존재하지 않음."""
|
||||
|
||||
|
||||
class ProtocolTransitionConflict(ProtocolRegistryError):
|
||||
"""현재 상태에서 요청한 전환을 수행할 수 없음."""
|
||||
|
||||
|
||||
class ProtocolPolicyViolation(ProtocolRegistryError):
|
||||
"""라이선스·콘텐츠 정책 위반."""
|
||||
|
||||
|
||||
class ProtocolStoreUnavailable(ProtocolRegistryError):
|
||||
"""DB 스키마 또는 저장소를 사용할 수 없음."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProtocolRecord:
|
||||
protocol_id: str
|
||||
source_id: str
|
||||
title: str
|
||||
source: str
|
||||
version: int
|
||||
license: ProtocolLicense
|
||||
external_llm_ok: bool
|
||||
content: str
|
||||
content_hash: str
|
||||
status: ProtocolStatus
|
||||
registered_by: str
|
||||
registered_at: datetime
|
||||
activated_at: datetime | None
|
||||
retired_at: datetime | None
|
||||
|
||||
|
||||
PROTOCOL_SCHEMA_SQL = """
|
||||
CREATE SCHEMA IF NOT EXISTS kb;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
JOIN pg_class t ON t.oid = c.conrelid
|
||||
JOIN pg_namespace n ON n.oid = t.relnamespace
|
||||
WHERE n.nspname = 'kb'
|
||||
AND t.relname = 'source'
|
||||
AND c.conname = 'ck_kb_source_external_license'
|
||||
) THEN
|
||||
ALTER TABLE kb.source
|
||||
ADD CONSTRAINT ck_kb_source_external_license
|
||||
CHECK (license_class IN ('A','B') OR external_llm_ok = FALSE) NOT VALID;
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS kb.protocol_registration (
|
||||
protocol_id UUID PRIMARY KEY,
|
||||
source_id TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL CHECK (btrim(title) <> ''),
|
||||
source_ref TEXT NOT NULL CHECK (btrim(source_ref) <> ''),
|
||||
version INT NOT NULL CHECK (version > 0),
|
||||
license_class CHAR(1) NOT NULL CHECK (license_class IN ('A','B','C','D')),
|
||||
external_llm_ok BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
content TEXT NOT NULL CHECK (btrim(content) <> ''),
|
||||
content_hash CHAR(64) NOT NULL CHECK (content_hash ~ '^[0-9a-f]{64}$'),
|
||||
status TEXT NOT NULL DEFAULT 'draft'
|
||||
CHECK (status IN ('draft','active','retired')),
|
||||
registered_by UUID NOT NULL REFERENCES app.app_user(user_id) ON DELETE RESTRICT,
|
||||
registered_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
activated_at TIMESTAMPTZ,
|
||||
retired_at TIMESTAMPTZ,
|
||||
CONSTRAINT ck_protocol_external_license
|
||||
CHECK (license_class IN ('A','B') OR external_llm_ok = FALSE),
|
||||
CONSTRAINT ck_protocol_lifecycle_timestamps CHECK (
|
||||
(status = 'draft' AND activated_at IS NULL AND retired_at IS NULL)
|
||||
OR (status = 'active' AND activated_at IS NOT NULL AND retired_at IS NULL)
|
||||
OR (status = 'retired' AND activated_at IS NOT NULL AND retired_at IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_protocol_registration_status
|
||||
ON kb.protocol_registration(status, registered_at DESC);
|
||||
|
||||
ALTER TABLE kb.chunk ENABLE ROW LEVEL SECURITY;
|
||||
DROP POLICY IF EXISTS p_kb_chunk_admin_write ON kb.chunk;
|
||||
CREATE POLICY p_kb_chunk_admin_write ON kb.chunk
|
||||
FOR ALL
|
||||
USING (app.current_role_name() = 'admin')
|
||||
WITH CHECK (app.current_role_name() = 'admin');
|
||||
"""
|
||||
|
||||
PROTOCOL_READINESS_SQL = """
|
||||
SELECT
|
||||
to_regclass('kb.protocol_registration') IS NOT NULL AS protocol_table,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
WHERE c.conrelid = to_regclass('kb.source')
|
||||
AND c.conname = 'ck_kb_source_external_license'
|
||||
AND c.convalidated
|
||||
) AS source_license_constraint,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
WHERE c.conrelid = to_regclass('kb.protocol_registration')
|
||||
AND c.conname = 'ck_protocol_external_license'
|
||||
AND c.convalidated
|
||||
) AS protocol_license_constraint,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint c
|
||||
WHERE c.conrelid = to_regclass('kb.protocol_registration')
|
||||
AND c.conname = 'ck_protocol_lifecycle_timestamps'
|
||||
AND c.convalidated
|
||||
) AS protocol_lifecycle_constraint,
|
||||
EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_policies p
|
||||
WHERE p.schemaname = 'kb'
|
||||
AND p.tablename = 'chunk'
|
||||
AND p.policyname = 'p_kb_chunk_admin_write'
|
||||
AND p.cmd = 'ALL'
|
||||
) AS protocol_chunk_write_policy,
|
||||
to_regclass('kb.idx_protocol_registration_status') IS NOT NULL AS protocol_status_index
|
||||
"""
|
||||
|
||||
_PROTOCOL_READINESS_FIELDS = (
|
||||
"protocol_table",
|
||||
"source_license_constraint",
|
||||
"protocol_license_constraint",
|
||||
"protocol_lifecycle_constraint",
|
||||
"protocol_chunk_write_policy",
|
||||
"protocol_status_index",
|
||||
)
|
||||
|
||||
_SELECT_COLUMNS = """
|
||||
protocol_id::text AS protocol_id,
|
||||
source_id,
|
||||
title,
|
||||
source_ref,
|
||||
version,
|
||||
license_class,
|
||||
external_llm_ok,
|
||||
content,
|
||||
content_hash,
|
||||
status,
|
||||
registered_by::text AS registered_by,
|
||||
registered_at,
|
||||
activated_at,
|
||||
retired_at
|
||||
"""
|
||||
|
||||
|
||||
def canonical_content(content: str) -> str:
|
||||
"""플랫폼별 줄바꿈 차이를 제거한 해시·저장 공통 원문."""
|
||||
|
||||
return content.replace("\r\n", "\n").replace("\r", "\n").strip()
|
||||
|
||||
|
||||
def content_hash(content: str) -> str:
|
||||
"""정규화된 전체 원문의 SHA-256."""
|
||||
|
||||
return hashlib.sha256(canonical_content(content).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_license_policy(license_class: str, external_llm_ok: bool) -> None:
|
||||
"""C/D 저작물은 외부 LLM 허용으로 등록할 수 없다."""
|
||||
|
||||
if license_class not in {"A", "B", "C", "D"}:
|
||||
raise ProtocolPolicyViolation("라이선스는 A, B, C, D 중 하나여야 합니다.")
|
||||
if license_class in {"C", "D"} and external_llm_ok:
|
||||
raise ProtocolPolicyViolation(
|
||||
"라이선스 C/D 프로토콜은 외부 LLM 사용을 허용할 수 없습니다."
|
||||
)
|
||||
|
||||
|
||||
def _split_long_block(block: str, limit: int) -> list[str]:
|
||||
chunks: list[str] = []
|
||||
remainder = block.strip()
|
||||
while len(remainder) > limit:
|
||||
cut = max(remainder.rfind("\n", 0, limit + 1), remainder.rfind(" ", 0, limit + 1))
|
||||
if cut < limit // 2:
|
||||
cut = limit
|
||||
chunks.append(remainder[:cut].strip())
|
||||
remainder = remainder[cut:].strip()
|
||||
if remainder:
|
||||
chunks.append(remainder)
|
||||
return chunks
|
||||
|
||||
|
||||
def build_index_chunks(record: ProtocolRecord, *, limit: int = 1800) -> list[dict[str, Any]]:
|
||||
"""원문을 결정론적 문단 청크로 바꾸되 라이선스 메타데이터를 보존한다."""
|
||||
|
||||
blocks = [item.strip() for item in re.split(r"\n{2,}", record.content) if item.strip()]
|
||||
chunk_texts: list[str] = []
|
||||
pending = ""
|
||||
for block in blocks:
|
||||
candidate = f"{pending}\n\n{block}".strip() if pending else block
|
||||
if len(candidate) <= limit:
|
||||
pending = candidate
|
||||
continue
|
||||
if pending:
|
||||
chunk_texts.append(pending)
|
||||
pending = ""
|
||||
pieces = _split_long_block(block, limit)
|
||||
chunk_texts.extend(pieces[:-1])
|
||||
pending = pieces[-1] if pieces else ""
|
||||
if pending:
|
||||
chunk_texts.append(pending)
|
||||
if not chunk_texts and record.content:
|
||||
chunk_texts = _split_long_block(record.content, limit)
|
||||
|
||||
context = f"{record.title} · 버전 {record.version} · 출처 {record.source}"
|
||||
return [
|
||||
{
|
||||
"seq": seq,
|
||||
"heading_path": record.title,
|
||||
"chunk_text": text,
|
||||
"context_prefix": context,
|
||||
"kb_kind": "theory",
|
||||
# 등록 프로토콜 원문은 평가/슈퍼비전 경로에서만 회수한다.
|
||||
# 내담자·상담사 생성 루프에는 요약이라도 흘리지 않는다.
|
||||
"visible_to": ["evaluator"],
|
||||
"sensitivity": 2,
|
||||
"meta": {
|
||||
"protocol_id": record.protocol_id,
|
||||
"protocol_status": "active",
|
||||
"source_title": record.title,
|
||||
"source_ref": record.source,
|
||||
"source_version": record.version,
|
||||
"license_class": record.license,
|
||||
"external_llm_ok": record.external_llm_ok,
|
||||
},
|
||||
"token_count": max(1, len(text) // 4),
|
||||
}
|
||||
for seq, text in enumerate(chunk_texts)
|
||||
]
|
||||
|
||||
|
||||
def _record(row: Any) -> ProtocolRecord:
|
||||
try:
|
||||
return ProtocolRecord(
|
||||
protocol_id=str(row["protocol_id"]),
|
||||
source_id=str(row["source_id"]),
|
||||
title=str(row["title"]),
|
||||
source=str(row["source_ref"]),
|
||||
version=int(row["version"]),
|
||||
license=str(row["license_class"]), # type: ignore[arg-type]
|
||||
external_llm_ok=bool(row["external_llm_ok"]),
|
||||
content=str(row["content"]),
|
||||
content_hash=str(row["content_hash"]),
|
||||
status=str(row["status"]), # type: ignore[arg-type]
|
||||
registered_by=str(row["registered_by"]),
|
||||
registered_at=row["registered_at"],
|
||||
activated_at=row["activated_at"],
|
||||
retired_at=row["retired_at"],
|
||||
)
|
||||
except (KeyError, TypeError, ValueError) as exc:
|
||||
raise ProtocolStoreUnavailable("프로토콜 저장 행의 계약이 올바르지 않습니다.") from exc
|
||||
|
||||
|
||||
async def ensure_protocol_tables() -> None:
|
||||
"""앱 역할로 owner migration 17의 완전 적용 여부만 확인한다."""
|
||||
|
||||
from ..db import acquire
|
||||
|
||||
try:
|
||||
async with acquire(role="admin") as conn:
|
||||
row = await conn.fetchrow(PROTOCOL_READINESS_SQL)
|
||||
except Exception as exc:
|
||||
raise ProtocolStoreUnavailable(
|
||||
f"프로토콜 스키마 준비 상태를 확인하지 못했습니다: {exc}"
|
||||
) from exc
|
||||
|
||||
missing = [
|
||||
field
|
||||
for field in _PROTOCOL_READINESS_FIELDS
|
||||
if row is None or row[field] is not True
|
||||
]
|
||||
if missing:
|
||||
raise ProtocolStoreUnavailable(
|
||||
"프로토콜 스키마가 불완전합니다. owner 권한으로 "
|
||||
"infra/db/init/17_improvement_workbook_contracts.sql을 적용해야 합니다: "
|
||||
+ ", ".join(missing)
|
||||
)
|
||||
|
||||
|
||||
async def create_protocol(
|
||||
conn: Any,
|
||||
*,
|
||||
title: str,
|
||||
source: str,
|
||||
version: int,
|
||||
license_class: ProtocolLicense,
|
||||
external_llm_ok: bool,
|
||||
content: str,
|
||||
registered_by: str,
|
||||
) -> ProtocolRecord:
|
||||
validate_license_policy(license_class, external_llm_ok)
|
||||
normalized = canonical_content(content)
|
||||
if not title.strip() or not source.strip() or not normalized:
|
||||
raise ProtocolPolicyViolation("제목, 출처, 내용은 비워 둘 수 없습니다.")
|
||||
|
||||
protocol_id = str(uuid4())
|
||||
source_id = f"protocol:{protocol_id}"
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
INSERT INTO kb.protocol_registration
|
||||
(protocol_id, source_id, title, source_ref, version, license_class,
|
||||
external_llm_ok, content, content_hash, status, registered_by)
|
||||
VALUES
|
||||
($1::uuid, $2, $3, $4, $5, $6, $7, $8, $9, 'draft', $10::uuid)
|
||||
RETURNING {_SELECT_COLUMNS}
|
||||
""",
|
||||
protocol_id,
|
||||
source_id,
|
||||
title.strip(),
|
||||
source.strip(),
|
||||
version,
|
||||
license_class,
|
||||
external_llm_ok,
|
||||
normalized,
|
||||
content_hash(normalized),
|
||||
registered_by,
|
||||
)
|
||||
except ProtocolRegistryError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ProtocolStoreUnavailable(f"프로토콜 초안을 저장하지 못했습니다: {exc}") from exc
|
||||
if row is None:
|
||||
raise ProtocolStoreUnavailable("프로토콜 초안 저장 결과가 비어 있습니다.")
|
||||
return _record(row)
|
||||
|
||||
|
||||
async def list_protocols(
|
||||
conn: Any,
|
||||
*,
|
||||
status_filter: ProtocolStatus | None = None,
|
||||
search: str | None = None,
|
||||
) -> list[ProtocolRecord]:
|
||||
search_text = (search or "").strip()
|
||||
try:
|
||||
rows = await conn.fetch(
|
||||
f"""
|
||||
SELECT {_SELECT_COLUMNS}
|
||||
FROM kb.protocol_registration
|
||||
WHERE ($1::text IS NULL OR status = $1)
|
||||
AND (
|
||||
$2 = ''
|
||||
OR title ILIKE '%' || $2 || '%'
|
||||
OR source_ref ILIKE '%' || $2 || '%'
|
||||
)
|
||||
ORDER BY registered_at DESC, protocol_id DESC
|
||||
LIMIT 200
|
||||
""",
|
||||
status_filter,
|
||||
search_text,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ProtocolStoreUnavailable(f"프로토콜 목록을 불러오지 못했습니다: {exc}") from exc
|
||||
return [_record(row) for row in rows]
|
||||
|
||||
|
||||
async def _locked_protocol(conn: Any, protocol_id: str) -> ProtocolRecord:
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
SELECT {_SELECT_COLUMNS}
|
||||
FROM kb.protocol_registration
|
||||
WHERE protocol_id = $1::uuid
|
||||
FOR UPDATE
|
||||
""",
|
||||
protocol_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ProtocolStoreUnavailable(f"프로토콜 상태를 확인하지 못했습니다: {exc}") from exc
|
||||
if row is None:
|
||||
raise ProtocolNotFound("프로토콜을 찾을 수 없습니다.")
|
||||
return _record(row)
|
||||
|
||||
|
||||
async def activate_protocol(
|
||||
conn: Any,
|
||||
*,
|
||||
protocol_id: str,
|
||||
) -> tuple[ProtocolRecord, rag.IndexResult]:
|
||||
"""draft를 색인한 뒤 active로 전환한다. 호출자는 DB 트랜잭션을 소유해야 한다."""
|
||||
|
||||
current = await _locked_protocol(conn, protocol_id)
|
||||
if current.status != "draft":
|
||||
raise ProtocolTransitionConflict("초안 상태의 프로토콜만 활성화할 수 있습니다.")
|
||||
validate_license_policy(current.license, current.external_llm_ok)
|
||||
|
||||
try:
|
||||
await conn.execute(
|
||||
"""
|
||||
INSERT INTO kb.source
|
||||
(source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok)
|
||||
VALUES ($1, $2, 'theory', $3, $4, $4, $5)
|
||||
ON CONFLICT (source_id) DO UPDATE SET
|
||||
title = EXCLUDED.title,
|
||||
kb_kind = EXCLUDED.kb_kind,
|
||||
license_class = EXCLUDED.license_class,
|
||||
origin_path = EXCLUDED.origin_path,
|
||||
citation = EXCLUDED.citation,
|
||||
external_llm_ok = EXCLUDED.external_llm_ok
|
||||
""",
|
||||
current.source_id,
|
||||
current.title,
|
||||
current.license,
|
||||
current.source,
|
||||
current.external_llm_ok,
|
||||
)
|
||||
index_result = await rag.index_document(
|
||||
conn,
|
||||
rag.IndexRequest(
|
||||
source_id=current.source_id,
|
||||
doc_uri=current.source,
|
||||
version=current.version,
|
||||
content_hash=current.content_hash,
|
||||
chunks=build_index_chunks(current),
|
||||
),
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
UPDATE kb.protocol_registration
|
||||
SET status = 'active', activated_at = now(), retired_at = NULL
|
||||
WHERE protocol_id = $1::uuid AND status = 'draft'
|
||||
RETURNING {_SELECT_COLUMNS}
|
||||
""",
|
||||
protocol_id,
|
||||
)
|
||||
except (rag.IndexPolicyViolation, rag.NotConfigured) as exc:
|
||||
raise ProtocolStoreUnavailable(f"프로토콜 색인을 완료하지 못했습니다: {exc}") from exc
|
||||
except ProtocolRegistryError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ProtocolStoreUnavailable(f"프로토콜을 활성화하지 못했습니다: {exc}") from exc
|
||||
if row is None:
|
||||
raise ProtocolTransitionConflict("프로토콜 상태가 바뀌어 활성화를 완료하지 못했습니다.")
|
||||
return _record(row), index_result
|
||||
|
||||
|
||||
async def retire_protocol(conn: Any, *, protocol_id: str) -> ProtocolRecord:
|
||||
"""active 프로토콜의 문서를 먼저 비활성화하고 retired로 전환한다."""
|
||||
|
||||
current = await _locked_protocol(conn, protocol_id)
|
||||
if current.status != "active":
|
||||
raise ProtocolTransitionConflict("활성 상태의 프로토콜만 퇴역할 수 있습니다.")
|
||||
try:
|
||||
await conn.execute(
|
||||
"UPDATE kb.document SET is_active = FALSE WHERE source_id = $1 AND is_active",
|
||||
current.source_id,
|
||||
)
|
||||
row = await conn.fetchrow(
|
||||
f"""
|
||||
UPDATE kb.protocol_registration
|
||||
SET status = 'retired', retired_at = now()
|
||||
WHERE protocol_id = $1::uuid AND status = 'active'
|
||||
RETURNING {_SELECT_COLUMNS}
|
||||
""",
|
||||
protocol_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise ProtocolStoreUnavailable(f"프로토콜을 퇴역하지 못했습니다: {exc}") from exc
|
||||
if row is None:
|
||||
raise ProtocolTransitionConflict("프로토콜 상태가 바뀌어 퇴역을 완료하지 못했습니다.")
|
||||
return _record(row)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"PROTOCOL_READINESS_SQL",
|
||||
"PROTOCOL_SCHEMA_SQL",
|
||||
"ProtocolLicense",
|
||||
"ProtocolNotFound",
|
||||
"ProtocolPolicyViolation",
|
||||
"ProtocolRecord",
|
||||
"ProtocolRegistryError",
|
||||
"ProtocolStatus",
|
||||
"ProtocolStoreUnavailable",
|
||||
"ProtocolTransitionConflict",
|
||||
"activate_protocol",
|
||||
"build_index_chunks",
|
||||
"canonical_content",
|
||||
"content_hash",
|
||||
"create_protocol",
|
||||
"ensure_protocol_tables",
|
||||
"list_protocols",
|
||||
"retire_protocol",
|
||||
"validate_license_policy",
|
||||
]
|
||||
|
|
@ -137,6 +137,8 @@ class RetrievedChunk:
|
|||
label_id: Optional[int] = None # 평가 정책에서만
|
||||
meta: dict[str, Any] = field(default_factory=dict)
|
||||
source_id: Optional[str] = None
|
||||
license_class: Optional[str] = None
|
||||
external_llm_ok: bool = False
|
||||
dense_score: float = 0.0
|
||||
sparse_score: float = 0.0
|
||||
|
||||
|
|
@ -297,8 +299,12 @@ WITH params AS (
|
|||
dense AS (
|
||||
SELECT c.chunk_id,
|
||||
1 - (c.embedding <=> p.q_dense) AS s_dense
|
||||
FROM kb.chunk c, params p
|
||||
FROM kb.chunk c
|
||||
JOIN kb.document d ON d.doc_id = c.doc_id AND d.is_active
|
||||
LEFT JOIN kb.protocol_registration pr ON pr.source_id = c.source_id
|
||||
CROSS JOIN params p
|
||||
WHERE c.embedding IS NOT NULL
|
||||
AND (pr.protocol_id IS NULL OR pr.status = 'active')
|
||||
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
|
||||
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
|
||||
AND $4 = ANY(c.visible_to)
|
||||
|
|
@ -309,8 +315,12 @@ dense AS (
|
|||
sparse AS (
|
||||
SELECT c.chunk_id,
|
||||
ts_rank_cd(to_tsvector('simple', c.chunk_text), p.q_ts) AS s_sparse
|
||||
FROM kb.chunk c, params p
|
||||
FROM kb.chunk c
|
||||
JOIN kb.document d ON d.doc_id = c.doc_id AND d.is_active
|
||||
LEFT JOIN kb.protocol_registration pr ON pr.source_id = c.source_id
|
||||
CROSS JOIN params p
|
||||
WHERE p.q_ts IS NOT NULL
|
||||
AND (pr.protocol_id IS NULL OR pr.status = 'active')
|
||||
AND to_tsvector('simple', c.chunk_text) @@ p.q_ts
|
||||
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
|
||||
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
|
||||
|
|
@ -329,9 +339,11 @@ fused AS (
|
|||
)
|
||||
SELECT c.chunk_id, c.kb_kind, c.heading_path, c.chunk_text, c.context_prefix,
|
||||
c.label_id, c.meta, c.source_id,
|
||||
src.license_class, src.external_llm_ok,
|
||||
f.s_dense, f.s_sparse, f.fused_score
|
||||
FROM fused f
|
||||
JOIN kb.chunk c USING (chunk_id)
|
||||
JOIN kb.source src ON src.source_id = c.source_id
|
||||
ORDER BY f.fused_score DESC
|
||||
LIMIT $9
|
||||
"""
|
||||
|
|
@ -537,6 +549,12 @@ async def search_kb(
|
|||
# asyncpg는 jsonb를 str(JSON text)로 반환 → 파싱. 코덱 등록 시 dict 그대로도 수용.
|
||||
_meta_raw = r["meta"]
|
||||
meta = json.loads(_meta_raw) if isinstance(_meta_raw, str) else dict(_meta_raw or {})
|
||||
# 청크 JSON 메타는 관리자 입력/과거 색인값일 수 있다. 외부 전송 정책은
|
||||
# 언제나 현재 kb.source 레코드가 권위 원본이며, 누락은 허용하지 않는다.
|
||||
license_class = str(r["license_class"] or "").strip().upper() or None
|
||||
external_llm_ok = r["external_llm_ok"] is True
|
||||
meta["license_class"] = license_class
|
||||
meta["external_llm_ok"] = external_llm_ok
|
||||
body = r["chunk_text"] if policy.expose_body else None
|
||||
cue = None
|
||||
if not policy.expose_body:
|
||||
|
|
@ -554,6 +572,8 @@ async def search_kb(
|
|||
label_id=r["label_id"] if policy.include_label else None,
|
||||
meta=meta if policy.include_label else {},
|
||||
source_id=r["source_id"],
|
||||
license_class=license_class,
|
||||
external_llm_ok=external_llm_ok,
|
||||
dense_score=float(r["s_dense"]),
|
||||
sparse_score=float(r["s_sparse"]),
|
||||
)
|
||||
|
|
@ -845,6 +865,48 @@ def _validate_index_chunks(req: IndexRequest) -> None:
|
|||
)
|
||||
|
||||
|
||||
async def validate_index_source(conn: "asyncpg.Connection", source_id: str) -> None:
|
||||
"""DB 등록 출처의 라이선스와 프로토콜 상태를 인덱싱 전에 강제한다.
|
||||
|
||||
호출자가 license/external_llm_ok 값을 임의로 보내는 우회는 허용하지 않는다.
|
||||
``kb.source`` 가 없거나 등록 프로토콜이 active가 아니면 fail closed한다.
|
||||
"""
|
||||
|
||||
try:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT s.license_class, s.external_llm_ok, pr.status AS protocol_status
|
||||
FROM kb.source s
|
||||
LEFT JOIN kb.protocol_registration pr ON pr.source_id = s.source_id
|
||||
WHERE s.source_id = $1
|
||||
""",
|
||||
source_id,
|
||||
)
|
||||
except Exception as exc:
|
||||
raise NotConfigured(f"kb source policy lookup failed: {exc}") from exc
|
||||
if row is None:
|
||||
raise IndexPolicyViolation(
|
||||
f"source must be registered in kb.source before indexing (source_id={source_id})"
|
||||
)
|
||||
|
||||
license_class = str(row["license_class"] or "").upper()
|
||||
external_llm_ok = bool(row["external_llm_ok"])
|
||||
if license_class not in {"A", "B", "C", "D"}:
|
||||
raise IndexPolicyViolation(f"source license is invalid (source_id={source_id})")
|
||||
if license_class in {"C", "D"} and external_llm_ok:
|
||||
raise IndexPolicyViolation(
|
||||
"license C/D sources cannot allow external LLM use "
|
||||
f"(source_id={source_id})"
|
||||
)
|
||||
|
||||
protocol_status = row["protocol_status"]
|
||||
if protocol_status is not None and str(protocol_status) != "active":
|
||||
raise IndexPolicyViolation(
|
||||
"registered protocols can be indexed only after activation "
|
||||
f"(source_id={source_id}, status={protocol_status})"
|
||||
)
|
||||
|
||||
|
||||
async def index_document(
|
||||
conn: "asyncpg.Connection",
|
||||
req: IndexRequest,
|
||||
|
|
@ -858,7 +920,7 @@ async def index_document(
|
|||
4. 임베딩 모델 미가용 시: embedding NULL 로 적재(텍스트만, BM25 만 동작) + degraded=True.
|
||||
|
||||
⚠️ 무거운 작업(임베딩) → 본래는 백그라운드 워커/배치. 라우트는 BackgroundTasks 로 위임 권장.
|
||||
DSM verbatim 저작권(license C/D): source.external_llm_ok=false 가드는 source 등록 시점 책임.
|
||||
라이선스와 등록 상태 검증은 호출부가 ``validate_index_source`` 로 DB 값을 확인해야 한다.
|
||||
|
||||
Raises: NotConfigured — DB(kb 스키마/vector) 미가용.
|
||||
"""
|
||||
|
|
@ -997,5 +1059,6 @@ __all__ = [
|
|||
"log_retrieval",
|
||||
"IndexRequest",
|
||||
"IndexResult",
|
||||
"validate_index_source",
|
||||
"index_document",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -4,12 +4,26 @@ from __future__ import annotations
|
|||
|
||||
from collections.abc import Iterable, Mapping
|
||||
|
||||
from .services import guardrail
|
||||
|
||||
def enriched_masked_turns(masked_turns: Iterable[Mapping[str, object]]) -> list[dict[str, object]]:
|
||||
"""Attach the same 1-based turn sequence to every session evaluation path."""
|
||||
|
||||
def enriched_masked_turns(
|
||||
masked_turns: Iterable[Mapping[str, object]],
|
||||
*,
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
) -> list[dict[str, object]]:
|
||||
"""Attach turn sequence and role-safe identity tokens to evaluation input."""
|
||||
enriched: list[dict[str, object]] = []
|
||||
for index, turn in enumerate(masked_turns, start=1):
|
||||
item = dict(turn)
|
||||
speaker = str(item.get("speaker") or "")
|
||||
item["text"] = guardrail.mask_role_identities(
|
||||
str(item.get("text") or ""),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=speaker == "client",
|
||||
).text_masked
|
||||
item["seq"] = index
|
||||
enriched.append(item)
|
||||
return enriched
|
||||
|
|
|
|||
|
|
@ -33,6 +33,11 @@ from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
|||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SessionCreationPersistenceError(RuntimeError):
|
||||
"""fail-closed 환경에서 영속 세션 생성이 실패했다."""
|
||||
|
||||
|
||||
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
|
||||
|
|
@ -54,8 +59,9 @@ 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.persona_id AS session_persona_id, s.persona_version AS session_persona_version,
|
||||
s.session_no, s.theory_mode, s.started_at, s.ended_at,
|
||||
s.prev_rapport_credit, s.session_goals,
|
||||
s.prev_rapport_credit, s.session_goals, s.learner_feedback_enabled,
|
||||
COALESCE(
|
||||
NULLIF(learner.display_name, ''),
|
||||
NULLIF(learner.nickname, ''),
|
||||
|
|
@ -148,6 +154,8 @@ class SessionEvaluationWrite:
|
|||
stage: str
|
||||
payload: dict[str, Any]
|
||||
error: str | None = None
|
||||
counselor_identity: str | None = None
|
||||
client_identity: str | None = None
|
||||
|
||||
@classmethod
|
||||
def from_result(
|
||||
|
|
@ -157,6 +165,8 @@ class SessionEvaluationWrite:
|
|||
learner_id: str,
|
||||
result: _SessionEvaluationResult,
|
||||
source: str = "engine",
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
) -> "SessionEvaluationWrite":
|
||||
return cls(
|
||||
session_id=session_id,
|
||||
|
|
@ -165,8 +175,20 @@ class SessionEvaluationWrite:
|
|||
source=source,
|
||||
scope=result.scope,
|
||||
stage=result.stage,
|
||||
payload=_mask_json_text_values(result.to_dict()),
|
||||
error=_clean_masked_text(result.error),
|
||||
payload=_mask_json_text_values(
|
||||
result.to_dict(),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=True,
|
||||
),
|
||||
error=_clean_role_masked_text(
|
||||
result.error,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=True,
|
||||
),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
|
|
@ -179,6 +201,8 @@ class SessionEvaluationWrite:
|
|||
stage: str,
|
||||
error: BaseException | str,
|
||||
source: str = "engine",
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
) -> "SessionEvaluationWrite":
|
||||
return cls(
|
||||
session_id=session_id,
|
||||
|
|
@ -188,7 +212,14 @@ class SessionEvaluationWrite:
|
|||
scope=scope,
|
||||
stage=stage,
|
||||
payload={},
|
||||
error=_clean_masked_text(_coerce_error_message(error)),
|
||||
error=_clean_role_masked_text(
|
||||
_coerce_error_message(error),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=True,
|
||||
),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
|
||||
def cache_record(self) -> dict[str, Any]:
|
||||
|
|
@ -484,11 +515,11 @@ def _live_coach_payload_from_record(record: dict[str, Any]) -> dict[str, Any]:
|
|||
return payload
|
||||
|
||||
|
||||
def _row_value(row, key: str):
|
||||
def _row_value(row, key: str, default: Any = None):
|
||||
try:
|
||||
return row[key]
|
||||
except Exception:
|
||||
return None
|
||||
return default
|
||||
|
||||
|
||||
def _card_from_joined_session_row(row) -> PersonaCard | None:
|
||||
|
|
@ -519,15 +550,72 @@ def _clean_masked_text(value: Any) -> str | None:
|
|||
return masked or None
|
||||
|
||||
|
||||
def _mask_json_text_values(value: Any) -> Any:
|
||||
def _clean_role_masked_text(
|
||||
value: Any,
|
||||
*,
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
synthetic_generated: bool = False,
|
||||
) -> str | None:
|
||||
text = _clean_text(value)
|
||||
if text is None:
|
||||
return None
|
||||
masked = guardrail.mask_role_identities(
|
||||
text,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=synthetic_generated,
|
||||
).text_masked.strip()
|
||||
return masked or None
|
||||
|
||||
|
||||
def _mask_json_text_values(
|
||||
value: Any,
|
||||
*,
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
synthetic_generated: bool = False,
|
||||
) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _clean_masked_text(value) or ""
|
||||
return (
|
||||
_clean_role_masked_text(
|
||||
value,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=synthetic_generated,
|
||||
)
|
||||
or ""
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return {key: _mask_json_text_values(child) for key, child in value.items()}
|
||||
return {
|
||||
key: _mask_json_text_values(
|
||||
child,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=synthetic_generated,
|
||||
)
|
||||
for key, child in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_mask_json_text_values(child) for child in value]
|
||||
return [
|
||||
_mask_json_text_values(
|
||||
child,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=synthetic_generated,
|
||||
)
|
||||
for child in value
|
||||
]
|
||||
if isinstance(value, tuple):
|
||||
return [_mask_json_text_values(child) for child in value]
|
||||
return [
|
||||
_mask_json_text_values(
|
||||
child,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=synthetic_generated,
|
||||
)
|
||||
for child in value
|
||||
]
|
||||
return value
|
||||
|
||||
|
||||
|
|
@ -1666,8 +1754,18 @@ async def ensure_review_tables() -> None:
|
|||
|
||||
|
||||
async def save_session_evaluation(write: SessionEvaluationWrite) -> bool:
|
||||
write.payload = _mask_json_text_values(write.payload)
|
||||
write.error = _clean_masked_text(write.error)
|
||||
write.payload = _mask_json_text_values(
|
||||
write.payload,
|
||||
counselor_identity=write.counselor_identity,
|
||||
client_identity=write.client_identity,
|
||||
synthetic_generated=True,
|
||||
)
|
||||
write.error = _clean_role_masked_text(
|
||||
write.error,
|
||||
counselor_identity=write.counselor_identity,
|
||||
client_identity=write.client_identity,
|
||||
synthetic_generated=True,
|
||||
)
|
||||
record = write.cache_record()
|
||||
if runtime_fallback_allowed():
|
||||
_EVALUATION_CACHE[write.session_id] = record
|
||||
|
|
@ -2417,10 +2515,15 @@ async def load_public_session_share(token_hash: str) -> dict[str, Any] | None:
|
|||
async with acquire(ai_context=True) as conn:
|
||||
row = await conn.fetchrow(
|
||||
"""
|
||||
SELECT session_id, payload, created_at, updated_at, revoked_at
|
||||
FROM app.session_share_link
|
||||
WHERE token_hash = $1
|
||||
AND revoked_at IS NULL
|
||||
SELECT share.session_id, share.payload, share.created_at,
|
||||
share.updated_at, share.revoked_at
|
||||
FROM app.session_share_link AS share
|
||||
JOIN app.sessions AS session ON session.id = share.session_id
|
||||
JOIN app.app_user AS learner ON learner.user_id = session.learner_id
|
||||
WHERE share.token_hash = $1
|
||||
AND share.revoked_at IS NULL
|
||||
AND session.learner_feedback_enabled IS TRUE
|
||||
AND learner.learner_feedback_enabled IS TRUE
|
||||
""",
|
||||
token_hash,
|
||||
)
|
||||
|
|
@ -2433,6 +2536,16 @@ async def load_public_session_share(token_hash: str) -> dict[str, Any] | None:
|
|||
cached = _SESSION_SHARE_CACHE.get(session_id)
|
||||
if not cached or cached.get("revoked_at") is not None:
|
||||
return None
|
||||
from . import auth_sessions
|
||||
from .store import store
|
||||
|
||||
session = store.get(session_id)
|
||||
if session is not None:
|
||||
if not bool(getattr(session, "learner_feedback_enabled", True)):
|
||||
return None
|
||||
learner = await auth_sessions.get_managed_user(session.learner_id)
|
||||
if learner is not None and not learner.learner_feedback_enabled:
|
||||
return None
|
||||
return {
|
||||
"session_id": str(cached["session_id"]),
|
||||
"payload": dict(cached.get("payload") or {}),
|
||||
|
|
@ -2555,6 +2668,16 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N
|
|||
theory_mode=row["theory_mode"] or "humanistic",
|
||||
persona=card,
|
||||
state=_state_from_row(state_row, card),
|
||||
persona_id=(
|
||||
str(_row_value(row, "session_persona_id"))
|
||||
if _row_value(row, "session_persona_id") is not None
|
||||
else None
|
||||
),
|
||||
persona_version=(
|
||||
int(_row_value(row, "session_persona_version"))
|
||||
if _row_value(row, "session_persona_version") is not None
|
||||
else None
|
||||
),
|
||||
session_no=int(row["session_no"] or 1),
|
||||
created_at=started_at,
|
||||
ended_at=ended_at,
|
||||
|
|
@ -2565,6 +2688,9 @@ def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | N
|
|||
goal_stages=[
|
||||
str(v) for v in _json_list_payload(_row_value(row, "session_goals"))
|
||||
],
|
||||
learner_feedback_enabled=bool(
|
||||
_row_value(row, "learner_feedback_enabled", True)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2641,6 +2767,7 @@ async def create_session(
|
|||
persona_version: int | None = None,
|
||||
case_id: str | None = None,
|
||||
goal_stages: list[str] | None = None,
|
||||
learner_feedback_enabled: bool = True,
|
||||
) -> InProcSession | None:
|
||||
"""Create a DB-backed session, returning None when DB persistence is unavailable."""
|
||||
try:
|
||||
|
|
@ -2685,12 +2812,13 @@ 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_goals
|
||||
session_no, theory_mode, stage_path, prev_rapport_credit, session_goals,
|
||||
learner_feedback_enabled
|
||||
)
|
||||
VALUES (
|
||||
$1::uuid, $2::uuid, $3::uuid, $4::uuid, $5,
|
||||
$6, $7, $8,
|
||||
$9, $10, '[]'::jsonb, $11, $12::jsonb
|
||||
$9, $10, '[]'::jsonb, $11, $12::jsonb, $13
|
||||
)
|
||||
RETURNING id, runtime_case_id, case_id, learner_id, persona_code,
|
||||
session_no, theory_mode, started_at, ended_at, prev_rapport_credit
|
||||
|
|
@ -2707,6 +2835,7 @@ async def create_session(
|
|||
theory_mode,
|
||||
carry_rapport,
|
||||
list(goal_stages or []),
|
||||
learner_feedback_enabled,
|
||||
)
|
||||
await _upsert_state(conn, str(row["id"]), state)
|
||||
return InProcSession(
|
||||
|
|
@ -2717,6 +2846,8 @@ async def create_session(
|
|||
theory_mode=theory_mode,
|
||||
persona=card,
|
||||
state=state,
|
||||
persona_id=pinned_persona_id,
|
||||
persona_version=pinned_persona_version,
|
||||
session_no=int(row["session_no"] or session_no),
|
||||
created_at=_ts(row["started_at"]) or time.time(),
|
||||
ended_at=None,
|
||||
|
|
@ -2724,10 +2855,21 @@ async def create_session(
|
|||
ended=False,
|
||||
prev_rapport_credit=carry_rapport,
|
||||
goal_stages=list(goal_stages or []),
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
)
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session creation")
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"durable session creation failed",
|
||||
extra={
|
||||
"learner_id": learner_id,
|
||||
"persona_code": card.code,
|
||||
"persona_id": persona_id,
|
||||
"persona_version": persona_version,
|
||||
},
|
||||
)
|
||||
if runtime_fallback_allowed():
|
||||
return None
|
||||
raise SessionCreationPersistenceError("session_persistence_unavailable") from exc
|
||||
|
||||
|
||||
async def load_session(
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import re
|
|||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Literal, Optional, cast
|
||||
from typing import Any, Callable, Literal, Optional, cast
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
|
@ -36,6 +36,10 @@ MISSING_SESSION_EVALUATION_GRACE_SECONDS = 30.0
|
|||
MISSING_SESSION_EVALUATION_ERROR = (
|
||||
"회기말 평가가 제한 시간 이후에도 저장되지 않았습니다. AI 평가 재시도가 필요합니다."
|
||||
)
|
||||
TRAINING_EXPOSURE_VERSION = "training-exposure-dominant-share.v1"
|
||||
TRAINING_EXPOSURE_MIN_COMPLETED = 4
|
||||
TRAINING_EXPOSURE_ATTENTION_THRESHOLD = 0.75
|
||||
FIRST_SESSION_CHECKLIST_VERSION = "first-session-rapport-open-question.v1"
|
||||
|
||||
|
||||
class LearnerSessionSummary(BaseModel):
|
||||
|
|
@ -51,6 +55,7 @@ class LearnerSessionSummary(BaseModel):
|
|||
started_at: str
|
||||
ended_at: str | None = None
|
||||
review_ready: bool = False
|
||||
learner_feedback_enabled: bool = True
|
||||
archived: bool = False
|
||||
archived_at: str | None = None
|
||||
|
||||
|
|
@ -111,6 +116,23 @@ class LearnerDashboardPersonaProgress(BaseModel):
|
|||
rapport_percent: int = 0
|
||||
|
||||
|
||||
class LearnerDashboardTrainingExposure(BaseModel):
|
||||
version: str = TRAINING_EXPOSURE_VERSION
|
||||
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
|
||||
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
|
||||
completed_sessions: int = 0
|
||||
minimum_completed_sessions: int = TRAINING_EXPOSURE_MIN_COMPLETED
|
||||
attention_threshold: float = TRAINING_EXPOSURE_ATTENTION_THRESHOLD
|
||||
dominant_persona_code: str | None = None
|
||||
dominant_persona_name: str | None = None
|
||||
dominant_sessions: int = 0
|
||||
dominant_share: float | None = None
|
||||
definition: str = (
|
||||
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, "
|
||||
"공정성 평가나 임상진단이 아닙니다."
|
||||
)
|
||||
|
||||
|
||||
class LearnerDashboardAchievement(BaseModel):
|
||||
id: str
|
||||
label: str
|
||||
|
|
@ -137,6 +159,9 @@ class LearnerDashboardResponse(BaseModel):
|
|||
overview: LearnerDashboardOverview
|
||||
growth: LearnerDashboardGrowth
|
||||
persona_progress: list[LearnerDashboardPersonaProgress] = Field(default_factory=list)
|
||||
training_exposure: LearnerDashboardTrainingExposure = Field(
|
||||
default_factory=LearnerDashboardTrainingExposure
|
||||
)
|
||||
achievements: list[LearnerDashboardAchievement] = Field(default_factory=list)
|
||||
recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list)
|
||||
message: str
|
||||
|
|
@ -230,6 +255,8 @@ class SessionDetailTurn(BaseModel):
|
|||
class SessionDetailResponse(BaseModel):
|
||||
session_id: str
|
||||
case_id: str
|
||||
persona_id: str | None = None
|
||||
persona_version: int | None = None
|
||||
persona_code: str
|
||||
persona_name: str
|
||||
theory_mode: str
|
||||
|
|
@ -240,6 +267,7 @@ class SessionDetailResponse(BaseModel):
|
|||
ended_at: str | None = None
|
||||
turns: list[SessionDetailTurn] = Field(default_factory=list)
|
||||
review_ready: bool = False
|
||||
learner_feedback_enabled: bool = True
|
||||
# 시간 기반 회기(2026-07-13 회의 P1): 새로고침 복원 시 타이머·목표 표시의 기준.
|
||||
goal_stages: list[StageLabel] = Field(default_factory=list)
|
||||
duration_limit_seconds: int = 0
|
||||
|
|
@ -306,6 +334,31 @@ class ReviewRubricRow(BaseModel):
|
|||
freq: str
|
||||
|
||||
|
||||
class ReviewFirstSessionChecklistEvidence(BaseModel):
|
||||
turnId: str
|
||||
turnSeq: int
|
||||
quote: str
|
||||
|
||||
|
||||
class ReviewFirstSessionChecklistCriterion(BaseModel):
|
||||
criterionId: str
|
||||
label: str
|
||||
description: str
|
||||
status: Literal["met", "not_observed"]
|
||||
evidenceTurns: list[ReviewFirstSessionChecklistEvidence] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReviewFirstSessionChecklist(BaseModel):
|
||||
version: str = FIRST_SESSION_CHECKLIST_VERSION
|
||||
applicable: bool = True
|
||||
status: Literal["ready", "not_applicable"] = "ready"
|
||||
title: str = "첫 회기 라포·개방질문 체크리스트"
|
||||
note: str = (
|
||||
"축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다."
|
||||
)
|
||||
criteria: list[ReviewFirstSessionChecklistCriterion] = Field(default_factory=list)
|
||||
|
||||
|
||||
class ReviewPoint(BaseModel):
|
||||
title: str
|
||||
body: str
|
||||
|
|
@ -421,6 +474,7 @@ class SessionTeacherReviewStatus(BaseModel):
|
|||
|
||||
class SessionReviewResponse(BaseModel):
|
||||
session_id: str
|
||||
sessionNo: int = Field(ge=1)
|
||||
client: ReviewClient
|
||||
date: str
|
||||
durationLabel: str
|
||||
|
|
@ -437,6 +491,7 @@ class SessionReviewResponse(BaseModel):
|
|||
counselorBaseline: list[ReviewValencePoint] = Field(default_factory=list)
|
||||
turns: list[ReviewTurn] = Field(default_factory=list)
|
||||
rubric: list[ReviewRubricRow] = Field(default_factory=list)
|
||||
firstSessionChecklist: ReviewFirstSessionChecklist | None = None
|
||||
goodMoments: list[ReviewPoint] = Field(default_factory=list)
|
||||
growthPoints: list[ReviewPoint] = Field(default_factory=list)
|
||||
caseWorksheet: ReviewCaseWorksheet = Field(default_factory=ReviewCaseWorksheet)
|
||||
|
|
@ -446,6 +501,7 @@ class SessionReviewResponse(BaseModel):
|
|||
pdfExportUrl: Optional[str] = None
|
||||
degraded: bool = True
|
||||
reviewReady: bool = False
|
||||
learnerFeedbackEnabled: bool = True
|
||||
teacherReview: SessionTeacherReviewStatus | None = None
|
||||
|
||||
|
||||
|
|
@ -469,6 +525,8 @@ class SessionReviewReadInput:
|
|||
saved_worksheet_payload: dict[str, object] | None = None
|
||||
include_teacher_review: bool = False
|
||||
teacher_review_record: dict[str, object] | None = None
|
||||
learner_feedback_enabled: bool = True
|
||||
expose_learner_feedback: bool = True
|
||||
now_ts: float | None = None
|
||||
|
||||
|
||||
|
|
@ -494,6 +552,7 @@ def learner_summary(
|
|||
sess: InProcSession,
|
||||
*,
|
||||
review_ready: bool = False,
|
||||
learner_feedback_enabled: bool | None = None,
|
||||
archived: bool = False,
|
||||
archived_at: str | None = None,
|
||||
) -> LearnerSessionSummary:
|
||||
|
|
@ -513,6 +572,11 @@ def learner_summary(
|
|||
started_at=iso(sess.created_at) or "",
|
||||
ended_at=iso(sess.ended_at),
|
||||
review_ready=review_ready,
|
||||
learner_feedback_enabled=(
|
||||
sess.learner_feedback_enabled
|
||||
if learner_feedback_enabled is None
|
||||
else learner_feedback_enabled
|
||||
),
|
||||
archived=archived,
|
||||
archived_at=archived_at,
|
||||
)
|
||||
|
|
@ -591,6 +655,7 @@ def dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth:
|
|||
def dashboard_persona_progress(
|
||||
sessions: list[InProcSession],
|
||||
review_ready: dict[str, bool],
|
||||
learner_feedback_enabled: dict[str, bool] | None = None,
|
||||
) -> list[LearnerDashboardPersonaProgress]:
|
||||
grouped: dict[str, list[InProcSession]] = {}
|
||||
for sess in sessions:
|
||||
|
|
@ -600,8 +665,17 @@ def dashboard_persona_progress(
|
|||
for persona_code, items in grouped.items():
|
||||
ordered = sorted(items, key=session_metrics.session_activity_time)
|
||||
latest = ordered[-1]
|
||||
metric_sessions = (
|
||||
ordered
|
||||
if learner_feedback_enabled is None
|
||||
else [
|
||||
sess
|
||||
for sess in ordered
|
||||
if learner_feedback_enabled.get(sess.session_id, True)
|
||||
]
|
||||
)
|
||||
metrics = session_metrics.build_learner_growth(
|
||||
ordered,
|
||||
metric_sessions,
|
||||
learner_label=lambda _learner_id: "나",
|
||||
limit=1,
|
||||
)
|
||||
|
|
@ -632,6 +706,45 @@ def dashboard_persona_progress(
|
|||
)
|
||||
|
||||
|
||||
def dashboard_training_exposure(
|
||||
sessions: list[InProcSession],
|
||||
) -> LearnerDashboardTrainingExposure:
|
||||
"""Compute a transparent practice-exposure signal from ended sessions only."""
|
||||
|
||||
completed = [sess for sess in sessions if sess.ended]
|
||||
counts = Counter(sess.persona_code for sess in completed)
|
||||
if not counts:
|
||||
return LearnerDashboardTrainingExposure()
|
||||
|
||||
dominant_code, dominant_count = sorted(
|
||||
counts.items(),
|
||||
key=lambda item: (-item[1], item[0]),
|
||||
)[0]
|
||||
dominant_session = next(
|
||||
sess for sess in completed if sess.persona_code == dominant_code
|
||||
)
|
||||
total = len(completed)
|
||||
share = dominant_count / total
|
||||
if total < TRAINING_EXPOSURE_MIN_COMPLETED:
|
||||
status: Literal["insufficient", "attention", "balanced"] = "insufficient"
|
||||
label: Literal["판정 근거 부족", "훈련 집중 주의", "균형"] = "판정 근거 부족"
|
||||
elif share >= TRAINING_EXPOSURE_ATTENTION_THRESHOLD:
|
||||
status = "attention"
|
||||
label = "훈련 집중 주의"
|
||||
else:
|
||||
status = "balanced"
|
||||
label = "균형"
|
||||
return LearnerDashboardTrainingExposure(
|
||||
status=status,
|
||||
label=label,
|
||||
completed_sessions=total,
|
||||
dominant_persona_code=dominant_code,
|
||||
dominant_persona_name=dominant_session.persona.display_name,
|
||||
dominant_sessions=dominant_count,
|
||||
dominant_share=round(share, 4),
|
||||
)
|
||||
|
||||
|
||||
def _achievement_state(done: bool, available: bool) -> Literal["done", "available", "locked"]:
|
||||
if done:
|
||||
return "done"
|
||||
|
|
@ -705,11 +818,14 @@ def session_detail(
|
|||
sess: InProcSession,
|
||||
*,
|
||||
review_ready: bool = False,
|
||||
learner_feedback_enabled: bool | None = None,
|
||||
) -> SessionDetailResponse:
|
||||
turns = learner_visible_turns(sess)
|
||||
return SessionDetailResponse(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
persona_id=sess.persona_id,
|
||||
persona_version=sess.persona_version,
|
||||
persona_code=sess.persona_code,
|
||||
persona_name=sess.persona.display_name,
|
||||
theory_mode=sess.theory_mode,
|
||||
|
|
@ -729,6 +845,11 @@ def session_detail(
|
|||
for turn in turns
|
||||
],
|
||||
review_ready=review_ready,
|
||||
learner_feedback_enabled=(
|
||||
sess.learner_feedback_enabled
|
||||
if learner_feedback_enabled is None
|
||||
else learner_feedback_enabled
|
||||
),
|
||||
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,
|
||||
|
|
@ -820,6 +941,87 @@ def _clip_text(text: str, limit: int = 180) -> str:
|
|||
return f"{compact[: max(0, limit - 1)].rstrip()}..."
|
||||
|
||||
|
||||
_FIRST_SESSION_RAPPORT_CUES = (
|
||||
"마음",
|
||||
"느껴",
|
||||
"느낌",
|
||||
"힘들",
|
||||
"이해",
|
||||
"그랬군",
|
||||
"그랬구나",
|
||||
"천천히",
|
||||
"버티",
|
||||
)
|
||||
_FIRST_SESSION_OPEN_QUESTION_WORDS = ("어떤", "어떻게", "무엇", "언제", "어디", "어느")
|
||||
_FIRST_SESSION_CLOSED_QUESTION_CUES = ("맞죠", "그렇죠", "아니에요?", "했나요?", "인가요?")
|
||||
|
||||
|
||||
def _first_session_evidence(
|
||||
turns: list[ReviewTurn],
|
||||
predicate: Callable[[str], bool],
|
||||
) -> list[ReviewFirstSessionChecklistEvidence]:
|
||||
evidence: list[ReviewFirstSessionChecklistEvidence] = []
|
||||
for index, turn in enumerate(turns, start=1):
|
||||
if turn.speaker != "learner" or not predicate(turn.text):
|
||||
continue
|
||||
evidence.append(
|
||||
ReviewFirstSessionChecklistEvidence(
|
||||
turnId=turn.id,
|
||||
turnSeq=index,
|
||||
quote=_clip_text(turn.text, 120),
|
||||
)
|
||||
)
|
||||
if len(evidence) >= 3:
|
||||
break
|
||||
return evidence
|
||||
|
||||
|
||||
def first_session_checklist(
|
||||
*,
|
||||
session_no: int,
|
||||
turns: list[ReviewTurn],
|
||||
) -> ReviewFirstSessionChecklist:
|
||||
"""Build a non-clinical, evidence-linked first-session behavior checklist."""
|
||||
|
||||
if session_no != 1:
|
||||
return ReviewFirstSessionChecklist(
|
||||
applicable=False,
|
||||
status="not_applicable",
|
||||
criteria=[],
|
||||
)
|
||||
|
||||
rapport_evidence = _first_session_evidence(
|
||||
turns,
|
||||
lambda text: any(cue in text for cue in _FIRST_SESSION_RAPPORT_CUES),
|
||||
)
|
||||
open_question_evidence = _first_session_evidence(
|
||||
turns,
|
||||
lambda text: (
|
||||
text.count("?") == 1
|
||||
and any(word in text for word in _FIRST_SESSION_OPEN_QUESTION_WORDS)
|
||||
and not any(cue in text for cue in _FIRST_SESSION_CLOSED_QUESTION_CUES)
|
||||
),
|
||||
)
|
||||
return ReviewFirstSessionChecklist(
|
||||
criteria=[
|
||||
ReviewFirstSessionChecklistCriterion(
|
||||
criterionId="first-session.rapport-reflection",
|
||||
label="라포를 위한 정서·경험 반영",
|
||||
description="내담자의 정서나 경험을 평가·조언보다 먼저 반영한 발화를 찾습니다.",
|
||||
status="met" if rapport_evidence else "not_observed",
|
||||
evidenceTurns=rapport_evidence,
|
||||
),
|
||||
ReviewFirstSessionChecklistCriterion(
|
||||
criterionId="first-session.open-question-one-focus",
|
||||
label="한 초점의 개방형 질문",
|
||||
description="한 번에 한 초점으로 탐색을 넓히는 개방형 질문을 찾습니다.",
|
||||
status="met" if open_question_evidence else "not_observed",
|
||||
evidenceTurns=open_question_evidence,
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _point_title(text: str, fallback: str) -> str:
|
||||
compact = _clip_text(text, 72)
|
||||
for sep in (".", "。", "!", "?", "\n"):
|
||||
|
|
@ -1176,29 +1378,78 @@ def session_share_payload(review: SessionReviewResponse) -> dict[str, object]:
|
|||
}
|
||||
|
||||
|
||||
def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
|
||||
def _evaluation_payload(
|
||||
record: dict[str, object] | None,
|
||||
*,
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
) -> dict[str, object]:
|
||||
if not record:
|
||||
return {}
|
||||
payload = record.get("payload")
|
||||
if not isinstance(payload, dict):
|
||||
return {}
|
||||
masked = _mask_payload_text_values(payload)
|
||||
masked = _mask_payload_text_values(
|
||||
payload,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
return masked if isinstance(masked, dict) else {}
|
||||
|
||||
|
||||
def _mask_payload_text(value: object) -> str:
|
||||
return guardrail.mask_pii(str(value or "")).text_masked
|
||||
def _mask_payload_text(
|
||||
value: object,
|
||||
*,
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
) -> str:
|
||||
return guardrail.mask_role_identities(
|
||||
str(value or ""),
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
synthetic_generated=True,
|
||||
).text_masked
|
||||
|
||||
|
||||
def _mask_payload_text_values(value: Any) -> Any:
|
||||
def _mask_payload_text_values(
|
||||
value: Any,
|
||||
*,
|
||||
counselor_identity: str | None = None,
|
||||
client_identity: str | None = None,
|
||||
) -> Any:
|
||||
if isinstance(value, str):
|
||||
return _mask_payload_text(value)
|
||||
return _mask_payload_text(
|
||||
value,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return {str(key): _mask_payload_text_values(child) for key, child in value.items()}
|
||||
return {
|
||||
str(key): _mask_payload_text_values(
|
||||
child,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
for key, child in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_mask_payload_text_values(child) for child in value]
|
||||
return [
|
||||
_mask_payload_text_values(
|
||||
child,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
for child in value
|
||||
]
|
||||
if isinstance(value, tuple):
|
||||
return [_mask_payload_text_values(child) for child in value]
|
||||
return [
|
||||
_mask_payload_text_values(
|
||||
child,
|
||||
counselor_identity=counselor_identity,
|
||||
client_identity=client_identity,
|
||||
)
|
||||
for child in value
|
||||
]
|
||||
return value
|
||||
|
||||
|
||||
|
|
@ -1614,6 +1865,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
sess = read_input.session
|
||||
visible_turns = learner_visible_turns(sess)
|
||||
hidden_turns = len(visible_turns) != len(sess.turns)
|
||||
feedback_hidden = hidden_turns or not read_input.expose_learner_feedback
|
||||
|
||||
end_ts = sess.ended_at or read_input.now_ts or datetime.now().timestamp()
|
||||
duration_seconds = max(0, int(round(end_ts - sess.created_at)))
|
||||
|
|
@ -1628,17 +1880,29 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
|
||||
now_ts = read_input.now_ts or datetime.now().timestamp()
|
||||
evaluation_record = read_input.evaluation_record
|
||||
if evaluation_record is None and not hidden_turns:
|
||||
if evaluation_record is None and not feedback_hidden:
|
||||
evaluation_record = _missing_session_evaluation_record(
|
||||
sess,
|
||||
has_visible_turns=bool(visible_turns),
|
||||
now_ts=now_ts,
|
||||
)
|
||||
evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record)
|
||||
evaluation_status = (
|
||||
"" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else ""
|
||||
evaluation_payload = (
|
||||
{}
|
||||
if feedback_hidden
|
||||
else _evaluation_payload(
|
||||
evaluation_record,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
)
|
||||
evaluation_ready = not hidden_turns and evaluation_status == "ready"
|
||||
)
|
||||
evaluation_status = (
|
||||
""
|
||||
if feedback_hidden
|
||||
else str(evaluation_record.get("status") or "")
|
||||
if evaluation_record
|
||||
else ""
|
||||
)
|
||||
evaluation_ready = not feedback_hidden and evaluation_status == "ready"
|
||||
|
||||
first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at
|
||||
turns: list[ReviewTurn] = []
|
||||
|
|
@ -1646,25 +1910,35 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
speaker: Literal["learner", "client"] = (
|
||||
"learner" if turn.speaker == "counselor" else "client"
|
||||
)
|
||||
turn_eval = turn.evaluation if (speaker == "learner" and not hidden_turns) else None
|
||||
safe_turn_text = guardrail.mask_role_identities(
|
||||
turn.text_masked,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
synthetic_generated=speaker == "client",
|
||||
).text_masked
|
||||
turn_eval = turn.evaluation if (speaker == "learner" and not feedback_hidden) else None
|
||||
turns.append(
|
||||
ReviewTurn(
|
||||
id=f"t{index + 1}",
|
||||
turn_id=turn.turn_id,
|
||||
ts=_offset_label(turn.created_at - first_turn_ts),
|
||||
speaker=speaker,
|
||||
who="학습자" if speaker == "learner" else client_name,
|
||||
text=turn.text_masked,
|
||||
who="상담자" if speaker == "learner" else "내담자",
|
||||
text=safe_turn_text,
|
||||
techniques=_review_techniques_from_turn_eval(turn_eval),
|
||||
nonverbal=_review_nonverbal_events(turn) if speaker == "learner" else [],
|
||||
note=_review_note_from_turn_eval(turn_eval, turn.text_masked),
|
||||
nonverbal=(
|
||||
_review_nonverbal_events(turn)
|
||||
if speaker == "learner" and not feedback_hidden
|
||||
else []
|
||||
),
|
||||
note=_review_note_from_turn_eval(turn_eval, safe_turn_text),
|
||||
)
|
||||
)
|
||||
|
||||
# 감정 밸런스 타임라인 — 학습자 기준선 + 내담자 정서가(비공개 턴 존재 시 비산출)
|
||||
counselor_baseline: list[ReviewValencePoint] = []
|
||||
client_valence: list[ReviewValencePoint] = []
|
||||
if not hidden_turns:
|
||||
if not feedback_hidden:
|
||||
counselor_baseline = counselor_baseline_points(
|
||||
visible_turns,
|
||||
first_turn_ts=first_turn_ts,
|
||||
|
|
@ -1690,7 +1964,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
session_signal = "진행 중"
|
||||
|
||||
transcript_summary = _review_summary(
|
||||
client_name=client_name,
|
||||
client_name="내담자",
|
||||
reached_phase=reached_phase,
|
||||
turns=turns,
|
||||
)
|
||||
|
|
@ -1713,9 +1987,11 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
growth_points = _intent_deviation_points(evaluation_payload.get("intent_deviations"))
|
||||
next_line = _next_line_from_evaluation(evaluation_payload)
|
||||
|
||||
client_feedback = _latest_client_feedback(turns)
|
||||
review_degraded = bool(turns) and not evaluation_ready
|
||||
if evaluation_ready:
|
||||
client_feedback = None if feedback_hidden else _latest_client_feedback(turns)
|
||||
review_degraded = bool(turns) and not evaluation_ready and not feedback_hidden
|
||||
if not read_input.expose_learner_feedback:
|
||||
supervisor_state = "피드백 비활성"
|
||||
elif evaluation_ready:
|
||||
supervisor_state = "평가 완료"
|
||||
elif evaluation_status == "error":
|
||||
supervisor_state = "평가 실패"
|
||||
|
|
@ -1724,18 +2000,37 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
else:
|
||||
supervisor_state = "기록 대기"
|
||||
|
||||
summary = _review_summary_from_evaluation(
|
||||
summary = (
|
||||
"관리자가 이 계정의 AI 학습 피드백을 비활성화했습니다. "
|
||||
"회기 축어록과 가상 내담자 응답은 그대로 보존됩니다."
|
||||
if not read_input.expose_learner_feedback
|
||||
else _review_summary_from_evaluation(
|
||||
fallback=transcript_summary,
|
||||
evaluation_record=None if hidden_turns else evaluation_record,
|
||||
payload=evaluation_payload,
|
||||
)
|
||||
if evaluation_record and not hidden_turns and not read_input.evaluation_durable:
|
||||
)
|
||||
if evaluation_record and not feedback_hidden and not read_input.evaluation_durable:
|
||||
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
|
||||
|
||||
saved_worksheet = saved_case_worksheet_from_payload(
|
||||
read_input.saved_worksheet_payload
|
||||
)
|
||||
generated_worksheet = case_worksheet_from_turns(turns)
|
||||
case_worksheet = (
|
||||
saved_case_worksheet_from_payload(read_input.saved_worksheet_payload)
|
||||
or generated_worksheet
|
||||
if not read_input.expose_learner_feedback:
|
||||
case_worksheet = saved_worksheet or ReviewCaseWorksheet(
|
||||
status="empty",
|
||||
generatedBy="learner input only",
|
||||
limitations=[
|
||||
"AI 자동 초안은 표시하지 않습니다. 저장한 학습자 워크시트는 그대로 보존됩니다."
|
||||
],
|
||||
)
|
||||
else:
|
||||
case_worksheet = saved_worksheet or generated_worksheet
|
||||
first_session_review = (
|
||||
None
|
||||
if feedback_hidden
|
||||
else first_session_checklist(session_no=sess.session_no, turns=turns)
|
||||
)
|
||||
|
||||
teacher_review: SessionTeacherReviewStatus | None = None
|
||||
|
|
@ -1757,6 +2052,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
|
||||
return SessionReviewResponse(
|
||||
session_id=sess.session_id,
|
||||
sessionNo=sess.session_no,
|
||||
client=ReviewClient(
|
||||
name=client_name,
|
||||
initial=client_initial,
|
||||
|
|
@ -1777,6 +2073,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
counselorBaseline=counselor_baseline,
|
||||
turns=turns,
|
||||
rubric=rubric,
|
||||
firstSessionChecklist=first_session_review,
|
||||
goodMoments=good_moments,
|
||||
growthPoints=growth_points,
|
||||
caseWorksheet=case_worksheet,
|
||||
|
|
@ -1786,5 +2083,6 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
|
|||
pdfExportUrl=None,
|
||||
degraded=review_degraded,
|
||||
reviewReady=evaluation_ready,
|
||||
learnerFeedbackEnabled=read_input.learner_feedback_enabled,
|
||||
teacherReview=teacher_review,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -62,6 +62,10 @@ class InProcSession:
|
|||
theory_mode: str
|
||||
persona: PersonaCard
|
||||
state: SessionState
|
||||
# 승인된 불변 카드의 DB 식별자. 코드만으로는 같은 코드의 버전 핀을
|
||||
# 증명할 수 없으므로 런타임 read model에도 복합 키를 보존한다.
|
||||
persona_id: str | None = None
|
||||
persona_version: int | None = None
|
||||
session_no: int = 1
|
||||
created_at: float = field(default_factory=time.time)
|
||||
ended_at: Optional[float] = None
|
||||
|
|
@ -70,6 +74,8 @@ class InProcSession:
|
|||
prev_rapport_credit: float = 0.0 # carry-over delta 계산용
|
||||
learner_label: str | None = None
|
||||
goal_stages: list[str] = field(default_factory=list) # 이번 회기 목표 단계(학습자 선택, 최대 4)
|
||||
# 계정 설정 변경과 진행 중 회기의 race를 막는 회기 시작 시점 정책 스냅샷.
|
||||
learner_feedback_enabled: bool = True
|
||||
|
||||
def turns_visible_to(self, role: str) -> list[TurnRecord]:
|
||||
return [turn for turn in self.turns if turn.is_visible_to(role)]
|
||||
|
|
@ -96,9 +102,12 @@ class SessionStore:
|
|||
persona: PersonaCard,
|
||||
theory_mode: str,
|
||||
state: SessionState,
|
||||
persona_id: str | None = None,
|
||||
persona_version: int | None = None,
|
||||
session_no: int = 1,
|
||||
carry_rapport: float = 0.0,
|
||||
goal_stages: list[str] | None = None,
|
||||
learner_feedback_enabled: bool = True,
|
||||
) -> InProcSession:
|
||||
session_id = uuid4().hex
|
||||
case_id = uuid4().hex
|
||||
|
|
@ -110,9 +119,12 @@ class SessionStore:
|
|||
theory_mode=theory_mode,
|
||||
persona=persona,
|
||||
state=state,
|
||||
persona_id=persona_id,
|
||||
persona_version=persona_version,
|
||||
session_no=session_no,
|
||||
prev_rapport_credit=carry_rapport,
|
||||
goal_stages=list(goal_stages or []),
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
)
|
||||
self._sessions[session_id] = s
|
||||
return s
|
||||
|
|
|
|||
126
apps/api/app/test_access_logging.py
Normal file
126
apps/api/app/test_access_logging.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import unittest
|
||||
|
||||
from .access_logging import (
|
||||
UvicornAccessLogRedactionFilter,
|
||||
install_uvicorn_access_log_redaction,
|
||||
redact_access_log_target,
|
||||
)
|
||||
|
||||
|
||||
UVICORN_ACCESS_FORMAT = '%s - "%s %s HTTP/%s" %d'
|
||||
|
||||
|
||||
def _uvicorn_access_record(target: object) -> logging.LogRecord:
|
||||
return logging.LogRecord(
|
||||
name="uvicorn.access",
|
||||
level=logging.INFO,
|
||||
pathname=__file__,
|
||||
lineno=1,
|
||||
msg=UVICORN_ACCESS_FORMAT,
|
||||
args=(("127.0.0.1", 12345), "GET", target, "1.1", 302),
|
||||
exc_info=None,
|
||||
)
|
||||
|
||||
|
||||
class AccessLogRedactionTests(unittest.TestCase):
|
||||
def test_oauth_callback_query_is_removed_from_uvicorn_access_record(self) -> None:
|
||||
record = _uvicorn_access_record(
|
||||
"/auth/callback?code=oauth-code-secret&state=oauth-state-secret",
|
||||
)
|
||||
|
||||
self.assertTrue(UvicornAccessLogRedactionFilter().filter(record))
|
||||
|
||||
rendered = record.getMessage()
|
||||
self.assertIn('GET /auth/callback HTTP/1.1" 302', rendered)
|
||||
self.assertNotIn("oauth-code-secret", rendered)
|
||||
self.assertNotIn("oauth-state-secret", rendered)
|
||||
self.assertNotIn("?", rendered)
|
||||
|
||||
def test_callback_redirect_variant_also_redacts_query(self) -> None:
|
||||
target = "/auth/callback/?code=oauth-code-secret&state=oauth-state-secret"
|
||||
|
||||
self.assertEqual("/auth/callback/", redact_access_log_target(target))
|
||||
|
||||
def test_non_callback_query_is_not_changed(self) -> None:
|
||||
target = "/auth/login?provider=google&next=%2Flearn"
|
||||
record = _uvicorn_access_record(target)
|
||||
|
||||
UvicornAccessLogRedactionFilter().filter(record)
|
||||
|
||||
self.assertEqual(target, record.args[2])
|
||||
self.assertEqual(target, redact_access_log_target(target))
|
||||
|
||||
def test_non_string_uvicorn_target_is_preserved(self) -> None:
|
||||
marker = object()
|
||||
record = _uvicorn_access_record(marker)
|
||||
|
||||
UvicornAccessLogRedactionFilter().filter(record)
|
||||
|
||||
self.assertIs(marker, record.args[2])
|
||||
|
||||
def test_install_is_idempotent(self) -> None:
|
||||
logger = logging.getLogger("uvicorn.access")
|
||||
original_filters = list(logger.filters)
|
||||
try:
|
||||
logger.filters = [
|
||||
existing
|
||||
for existing in logger.filters
|
||||
if not isinstance(existing, UvicornAccessLogRedactionFilter)
|
||||
]
|
||||
|
||||
install_uvicorn_access_log_redaction()
|
||||
install_uvicorn_access_log_redaction()
|
||||
|
||||
installed = [
|
||||
existing
|
||||
for existing in logger.filters
|
||||
if isinstance(existing, UvicornAccessLogRedactionFilter)
|
||||
]
|
||||
self.assertEqual(1, len(installed))
|
||||
finally:
|
||||
logger.filters = original_filters
|
||||
|
||||
def test_installed_filter_redacts_an_emitted_uvicorn_access_message(self) -> None:
|
||||
logger = logging.getLogger("uvicorn.access")
|
||||
original_filters = list(logger.filters)
|
||||
original_handlers = list(logger.handlers)
|
||||
original_level = logger.level
|
||||
original_propagate = logger.propagate
|
||||
rendered: list[str] = []
|
||||
|
||||
class CaptureHandler(logging.Handler):
|
||||
def emit(self, record: logging.LogRecord) -> None:
|
||||
rendered.append(record.getMessage())
|
||||
|
||||
try:
|
||||
logger.filters = []
|
||||
logger.handlers = [CaptureHandler()]
|
||||
logger.setLevel(logging.INFO)
|
||||
logger.propagate = False
|
||||
install_uvicorn_access_log_redaction()
|
||||
|
||||
logger.info(
|
||||
UVICORN_ACCESS_FORMAT,
|
||||
("127.0.0.1", 12345),
|
||||
"GET",
|
||||
"/auth/callback?code=oauth-code-secret&state=oauth-state-secret",
|
||||
"1.1",
|
||||
302,
|
||||
)
|
||||
|
||||
self.assertEqual(1, len(rendered))
|
||||
self.assertIn("GET /auth/callback HTTP/1.1", rendered[0])
|
||||
self.assertNotIn("oauth-code-secret", rendered[0])
|
||||
self.assertNotIn("oauth-state-secret", rendered[0])
|
||||
finally:
|
||||
logger.filters = original_filters
|
||||
logger.handlers = original_handlers
|
||||
logger.setLevel(original_level)
|
||||
logger.propagate = original_propagate
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -183,6 +183,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
config = await auth_routes.auth_config(request)
|
||||
|
||||
self.assertTrue(config.dev_login_enabled)
|
||||
self.assertEqual(config.allowed_email_domains, [])
|
||||
|
||||
async def test_learner_can_accept_and_withdraw_practice_consent(self) -> None:
|
||||
with patch.object(auth_sessions, "get_pool", side_effect=RuntimeError("no db")):
|
||||
|
|
@ -328,7 +329,9 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
|
||||
self.assertEqual(me.cohort_ids, ["e2e-hanshin"])
|
||||
|
||||
async def test_admin_created_external_domain_user_can_login(self) -> None:
|
||||
async def test_google_identity_accepts_any_verified_domain_and_auto_approves(
|
||||
self,
|
||||
) -> None:
|
||||
with (
|
||||
patched_settings(
|
||||
environment="dev",
|
||||
|
|
@ -354,29 +357,57 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
admin_routes.AdminUserCreate(
|
||||
email="forced.gmail@gmail.com",
|
||||
display_name="Forced Gmail",
|
||||
role="teacher",
|
||||
account_status="approved",
|
||||
role="learner",
|
||||
cohort_ids=["manual-cohort"],
|
||||
),
|
||||
super_admin,
|
||||
)
|
||||
|
||||
me = await auth_routes.dev_login(
|
||||
_request(path="/auth/dev-login"),
|
||||
auth_routes.DevLoginRequest(
|
||||
email="forced.gmail@gmail.com",
|
||||
role="learner",
|
||||
display_name="Provider Name",
|
||||
self.assertEqual(created.account_status, "pending")
|
||||
self.assertEqual(
|
||||
auth_routes.validate_google_identity(
|
||||
email="unmanaged@gmail.com",
|
||||
email_verified=True,
|
||||
),
|
||||
Response(),
|
||||
"unmanaged@gmail.com",
|
||||
)
|
||||
google_email = auth_routes.validate_google_identity(
|
||||
email="forced.gmail@gmail.com",
|
||||
email_verified=True,
|
||||
)
|
||||
google_managed = await auth_sessions.get_managed_user_by_email(google_email)
|
||||
self.assertEqual(google_email, created.email)
|
||||
self.assertIsNotNone(google_managed)
|
||||
self.assertEqual(google_managed.user_id, created.user_id)
|
||||
|
||||
self.assertEqual(me.user_id, created.user_id)
|
||||
self.assertEqual(me.email, "forced.gmail@gmail.com")
|
||||
self.assertEqual(me.role, "teacher")
|
||||
self.assertEqual(me.account_status, "approved")
|
||||
self.assertEqual(me.cohort_ids, ["manual-cohort"])
|
||||
_, google_user = await auth_sessions.create_session(
|
||||
email="forced.gmail@gmail.com",
|
||||
display_name="Provider Name",
|
||||
role="learner",
|
||||
external_id="google:external-participant",
|
||||
account_status="approved",
|
||||
)
|
||||
self.assertEqual(google_user.user_id, created.user_id)
|
||||
self.assertEqual(google_user.account_status, "approved")
|
||||
self.assertEqual(google_user.cohort_ids, ["manual-cohort"])
|
||||
|
||||
suspended = await admin_routes.patch_user(
|
||||
created.user_id,
|
||||
admin_routes.AdminUserPatch(account_status="suspended"),
|
||||
super_admin,
|
||||
)
|
||||
self.assertEqual(suspended.account_status, "suspended")
|
||||
_, suspended_login = await auth_sessions.create_session(
|
||||
email="forced.gmail@gmail.com",
|
||||
display_name="Provider Name",
|
||||
role="learner",
|
||||
external_id="google:external-participant",
|
||||
account_status="approved",
|
||||
)
|
||||
self.assertEqual(suspended_login.account_status, "suspended")
|
||||
|
||||
# Google is open to every verified account, while the local dev-login
|
||||
# fixture remains constrained to its explicit test domains.
|
||||
with self.assertRaises(auth_routes.HTTPException) as denied:
|
||||
await auth_routes.dev_login(
|
||||
_request(path="/auth/dev-login"),
|
||||
|
|
@ -388,6 +419,14 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
Response(),
|
||||
)
|
||||
self.assertEqual(denied.exception.status_code, 403)
|
||||
self.assertEqual(denied.exception.detail, "email domain is not allowed")
|
||||
|
||||
with self.assertRaises(auth_routes.HTTPException) as unverified:
|
||||
auth_routes.validate_google_identity(
|
||||
email="unmanaged@gmail.com",
|
||||
email_verified=False,
|
||||
)
|
||||
self.assertEqual(unverified.exception.status_code, 403)
|
||||
|
||||
async def test_super_admin_email_is_admin_and_auto_approved(self) -> None:
|
||||
with (
|
||||
|
|
@ -664,6 +703,62 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIn("$7::boolean", conn.queries[0])
|
||||
self.assertIn("$9::boolean", conn.queries[1])
|
||||
|
||||
async def test_google_oauth_promotes_preregistered_pending_user_in_db(self) -> None:
|
||||
class RecordingConn:
|
||||
def __init__(self) -> None:
|
||||
self.query = ""
|
||||
self.args: tuple[Any, ...] = ()
|
||||
|
||||
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any]:
|
||||
self.query = query
|
||||
self.args = args
|
||||
now = datetime.now(timezone.utc)
|
||||
return {
|
||||
"user_id": "00000000-0000-0000-0000-000000000606",
|
||||
"email": "preregistered@gmail.com",
|
||||
"display_name": "Preregistered",
|
||||
"role": "learner",
|
||||
"admin_access": False,
|
||||
"account_status": "approved",
|
||||
"cohort": "pilot",
|
||||
"affiliation": "",
|
||||
"created_at": now,
|
||||
"last_seen_at": now,
|
||||
}
|
||||
|
||||
class RecordingAcquire:
|
||||
def __init__(self, conn: RecordingConn) -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def __aenter__(self) -> RecordingConn:
|
||||
return self.conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> None:
|
||||
return None
|
||||
|
||||
class RecordingPool:
|
||||
def __init__(self, conn: RecordingConn) -> None:
|
||||
self.conn = conn
|
||||
|
||||
def acquire(self) -> RecordingAcquire:
|
||||
return RecordingAcquire(self.conn)
|
||||
|
||||
conn = RecordingConn()
|
||||
with patch.object(auth_sessions, "get_pool", return_value=RecordingPool(conn)):
|
||||
user = await auth_sessions.upsert_managed_user(
|
||||
auth_sessions.ManagedUserUpsertInput(
|
||||
email="preregistered@gmail.com",
|
||||
display_name="Preregistered",
|
||||
role="learner",
|
||||
external_id="google:preregistered-sub",
|
||||
account_status="approved",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(user.account_status, "approved")
|
||||
self.assertIn("WHEN $8::text = 'approved' THEN 'approved'", conn.query)
|
||||
self.assertEqual(conn.args[7], "approved")
|
||||
|
||||
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
|
||||
request = _request(
|
||||
[
|
||||
|
|
@ -1040,6 +1135,82 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIn("oauth=local_oauth_unavailable", location)
|
||||
self.assertFalse(auth_routes._oauth_states)
|
||||
|
||||
async def test_google_callback_accepts_unregistered_external_email(self) -> None:
|
||||
state = "external-email-state"
|
||||
auth_routes._oauth_states[state] = auth_routes.OAuthState(
|
||||
code_verifier="verifier",
|
||||
next_path="/learn",
|
||||
created_at=1_800_000_000.0,
|
||||
)
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self, payload: dict[str, Any]) -> None:
|
||||
self.status_code = 200
|
||||
self._payload = payload
|
||||
|
||||
def json(self) -> dict[str, Any]:
|
||||
return self._payload
|
||||
|
||||
class FakeAsyncClient:
|
||||
def __init__(self, *args: Any, **kwargs: Any) -> None:
|
||||
pass
|
||||
|
||||
async def __aenter__(self) -> "FakeAsyncClient":
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
||||
return None
|
||||
|
||||
async def post(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
return FakeResponse({"id_token": "id-token"})
|
||||
|
||||
async def get(self, url: str, **kwargs: Any) -> FakeResponse:
|
||||
return FakeResponse(
|
||||
{
|
||||
"aud": "google-client",
|
||||
"iss": "https://accounts.google.com",
|
||||
"email": "unregistered@gmail.com",
|
||||
"email_verified": "true",
|
||||
"name": "Unregistered External",
|
||||
"sub": "google-external-sub",
|
||||
}
|
||||
)
|
||||
|
||||
create_session_mock = AsyncMock(return_value=("opaque-session", object()))
|
||||
with (
|
||||
patched_settings(
|
||||
oauth_google_client_id="google-client",
|
||||
oauth_google_client_secret="google-secret",
|
||||
frontend_base_url="https://vignette.test",
|
||||
environment="prod",
|
||||
auth_allowed_email_domains=["hs.ac.kr"],
|
||||
),
|
||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||
patch.object(
|
||||
auth_routes,
|
||||
"get_managed_user_by_email",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(auth_routes, "create_session", create_session_mock),
|
||||
):
|
||||
response = await auth_routes.callback(
|
||||
_request(),
|
||||
code="auth-code",
|
||||
state=state,
|
||||
oauth_state_cookie=state,
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 302)
|
||||
self.assertEqual(response.headers["location"], "https://vignette.test/learn")
|
||||
create_session_mock.assert_awaited_once_with(
|
||||
email="unregistered@gmail.com",
|
||||
display_name="Unregistered External",
|
||||
role="learner",
|
||||
cohort_ids=[],
|
||||
external_id="google:google-external-sub",
|
||||
account_status="approved",
|
||||
)
|
||||
|
||||
async def test_google_callback_sets_opaque_cookie_without_browser_tokens(self) -> None:
|
||||
state = "state-token"
|
||||
auth_routes._oauth_states[state] = auth_routes.OAuthState(
|
||||
|
|
@ -1099,6 +1270,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
auth_email_cohort_map={"learner@hs.ac.kr": "pilot-a"},
|
||||
),
|
||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||
patch.object(
|
||||
auth_routes,
|
||||
"get_managed_user_by_email",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(auth_routes, "create_session", create_session_mock),
|
||||
):
|
||||
response = await auth_routes.callback(
|
||||
|
|
@ -1116,6 +1292,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
role="learner",
|
||||
cohort_ids=["pilot-a", "hanshin-2026"],
|
||||
external_id="google:learner@hs.ac.kr",
|
||||
account_status="approved",
|
||||
)
|
||||
cookie_blob = "\n".join(
|
||||
value.decode("latin1")
|
||||
|
|
@ -1202,6 +1379,7 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
role="learner",
|
||||
cohort_ids=[],
|
||||
external_id="google:operator@hs.ac.kr",
|
||||
account_status="approved",
|
||||
)
|
||||
self.assertEqual(
|
||||
auth_routes._post_login_next_path(
|
||||
|
|
@ -1276,6 +1454,11 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
|||
session_secret="signed-oauth-state-secret",
|
||||
),
|
||||
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
|
||||
patch.object(
|
||||
auth_routes,
|
||||
"get_managed_user_by_email",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
patch.object(auth_routes, "create_session", AsyncMock(return_value=("opaque-session", object()))),
|
||||
):
|
||||
response = await auth_routes.callback(
|
||||
|
|
|
|||
|
|
@ -720,6 +720,45 @@ class CalibrationTransferStoreTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(result["prediction_histories"], [])
|
||||
self.assertEqual(result["transfer_suites"], [])
|
||||
self.assertFalse(result["clinical_claim_allowed"])
|
||||
self.assertTrue(result["_learner_feedback_snapshot_enabled"])
|
||||
|
||||
async def test_read_reports_disabled_historical_session_snapshot(self) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
history_id = uuid4()
|
||||
conn = AsyncMock()
|
||||
conn.fetch.side_effect = [
|
||||
[
|
||||
{
|
||||
"history_id": history_id,
|
||||
"session_id": uuid4(),
|
||||
"source_learner_feedback_enabled": False,
|
||||
}
|
||||
],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
[],
|
||||
]
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs):
|
||||
yield conn
|
||||
|
||||
with patch.object(calibration_transfer_store.db, "acquire", fake_acquire):
|
||||
result = await calibration_transfer_store.read_calibration_transfer(
|
||||
principal=principal
|
||||
)
|
||||
|
||||
self.assertFalse(result["_learner_feedback_snapshot_enabled"])
|
||||
self.assertNotIn(
|
||||
"source_learner_feedback_enabled",
|
||||
result["prediction_histories"][0],
|
||||
)
|
||||
history_query = conn.fetch.await_args_list[0].args[0]
|
||||
self.assertIn("source_session.learner_feedback_enabled", history_query)
|
||||
|
||||
|
||||
class CalibrationTransferInternalAuthenticationTests(
|
||||
|
|
|
|||
|
|
@ -2,12 +2,75 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import unittest
|
||||
|
||||
from .paths import repo_path
|
||||
from .services import guardrail
|
||||
|
||||
|
||||
class ClientReplyQualityGateTest(unittest.TestCase):
|
||||
def test_crisis_protocol_manifest_preserves_external_clinical_approval_gate(self) -> None:
|
||||
manifest = json.loads(
|
||||
repo_path("data", "clinical", "crisis-protocol-validation.json").read_text(
|
||||
encoding="utf-8",
|
||||
),
|
||||
)
|
||||
|
||||
self.assertEqual(manifest["technical_status"], "verified")
|
||||
self.assertEqual(manifest["clinical_status"], "pending_external_review")
|
||||
required_approval_fields = {
|
||||
"reviewer",
|
||||
"organization",
|
||||
"reviewed_at",
|
||||
"decision",
|
||||
}
|
||||
approval = manifest["approval"]
|
||||
self.assertTrue(required_approval_fields.issubset(approval))
|
||||
for field in required_approval_fields:
|
||||
self.assertIsNone(approval[field])
|
||||
technical_gate_text = " ".join(manifest["technical_gates"])
|
||||
for field in required_approval_fields:
|
||||
self.assertIn(f"approval.{field}", technical_gate_text)
|
||||
authorities = {source["authority"] for source in manifest["sources"]}
|
||||
self.assertEqual(authorities, {"SAMHSA", "NIMH", "대한민국 보건복지부"})
|
||||
self.assertGreaterEqual(len(manifest["review_sequence"]), 6)
|
||||
self.assertGreaterEqual(len(manifest["technical_gates"]), 5)
|
||||
|
||||
def test_crisis_protocol_technical_gates(self) -> None:
|
||||
counselor_inquiry = guardrail.classify_crisis(
|
||||
"최근에 자살 생각이 든 적이 있나요?",
|
||||
speaker_is_persona_context=True,
|
||||
)
|
||||
learner_crisis = guardrail.classify_crisis(
|
||||
"저 지금 자살하고 싶어요. 도와주세요.",
|
||||
speaker_is_persona_context=True,
|
||||
)
|
||||
client_signal = guardrail.sanitize_client_reply(
|
||||
"가끔 사라지고 싶다는 생각이 들지만, 아직 누구에게도 말하지 못했어요.",
|
||||
ideation_stage=2,
|
||||
turn_seq=4,
|
||||
)
|
||||
means_detail = guardrail.sanitize_client_reply(
|
||||
"면도날을 항상 가지고 다녀요.",
|
||||
ideation_stage=3,
|
||||
turn_seq=4,
|
||||
)
|
||||
over_cap = guardrail.sanitize_client_reply(
|
||||
"요즘 버티기 어렵다는 생각이 자주 들어요.",
|
||||
ideation_stage=4,
|
||||
turn_seq=4,
|
||||
)
|
||||
|
||||
self.assertEqual(counselor_inquiry.kind, guardrail.CrisisKind.PERSONA_PLAY)
|
||||
self.assertFalse(counselor_inquiry.escalate)
|
||||
self.assertEqual(learner_crisis.kind, guardrail.CrisisKind.LEARNER_REAL)
|
||||
self.assertTrue(learner_crisis.escalate)
|
||||
self.assertEqual(guardrail.crisis_resource()["number"], "109")
|
||||
self.assertFalse(client_signal.needs_regeneration)
|
||||
self.assertTrue(means_detail.needs_regeneration)
|
||||
self.assertTrue(over_cap.blocked)
|
||||
|
||||
def test_blocks_self_harm_means_detail(self) -> None:
|
||||
"""자해 수단·방법 상세는 차단(1.1 확장 — DBT 자해 페르소나 대비)."""
|
||||
samples = [
|
||||
|
|
|
|||
|
|
@ -211,6 +211,48 @@ class DeliberatePracticeStoreAsyncTests(unittest.IsolatedAsyncioTestCase):
|
|||
episode=self.pack.cases[0].episodes[0],
|
||||
)
|
||||
|
||||
async def test_attempt_rejects_disabled_prescription_source_before_replay(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
case = self.pack.cases[0]
|
||||
episode_payload = case.episodes[0].model_dump(mode="python")
|
||||
for attempt in episode_payload["attempts"]:
|
||||
for ref in attempt["evidence_refs"]:
|
||||
ref["ref_id"] = str(uuid4())
|
||||
for ref in attempt["criterion"]["evidence_refs"]:
|
||||
ref["ref_id"] = str(uuid4())
|
||||
episode = type(case.episodes[0]).model_validate(episode_payload)
|
||||
source_session_id = uuid4()
|
||||
conn = AsyncMock()
|
||||
conn.fetchrow.return_value = {
|
||||
"prescription_record_id": uuid4(),
|
||||
"session_id": source_session_id,
|
||||
"prescription_payload": {},
|
||||
"created_at": datetime.now(timezone.utc),
|
||||
"source_learner_feedback_enabled": False,
|
||||
}
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs):
|
||||
yield conn
|
||||
|
||||
with patch.object(deliberate_practice_store.db, "acquire", fake_acquire):
|
||||
with self.assertRaises(
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError
|
||||
):
|
||||
await deliberate_practice_store.append_learner_attempt_submission(
|
||||
principal=principal,
|
||||
submission_id=uuid4(),
|
||||
prescription_id=episode.prescription_id,
|
||||
episode=episode,
|
||||
)
|
||||
|
||||
self.assertEqual(conn.fetchrow.await_count, 1)
|
||||
source_query = conn.fetchrow.await_args.args[0]
|
||||
self.assertIn("source_session.learner_feedback_enabled", source_query)
|
||||
self.assertNotIn("app.practice_episode_submission", source_query)
|
||||
|
||||
async def test_runtime_observer_derives_attempt_from_later_ready_session(
|
||||
self,
|
||||
) -> None:
|
||||
|
|
@ -388,6 +430,71 @@ class DeliberatePracticeStoreAsyncTests(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertIsNone(result["competency_graph"])
|
||||
self.assertFalse(result["clinical_claim_allowed"])
|
||||
|
||||
async def test_learner_read_rejects_any_disabled_source_snapshot(self) -> None:
|
||||
principal = _principal(Role.LEARNER)
|
||||
conn = AsyncMock()
|
||||
conn.fetch.side_effect = [
|
||||
[
|
||||
{
|
||||
"prescription_record_id": uuid4(),
|
||||
"session_id": uuid4(),
|
||||
"source_learner_feedback_enabled": False,
|
||||
}
|
||||
],
|
||||
[],
|
||||
]
|
||||
conn.fetchrow.return_value = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs):
|
||||
yield conn
|
||||
|
||||
with patch.object(deliberate_practice_store.db, "acquire", fake_acquire):
|
||||
with self.assertRaises(
|
||||
deliberate_practice_store.DeliberatePracticeFeedbackDisabledError
|
||||
):
|
||||
await deliberate_practice_store.read_deliberate_practice(
|
||||
principal=principal
|
||||
)
|
||||
|
||||
prescription_query = conn.fetch.await_args_list[0].args[0]
|
||||
episode_query = conn.fetch.await_args_list[1].args[0]
|
||||
self.assertIn("source_session.learner_feedback_enabled", prescription_query)
|
||||
self.assertIn("source_session.learner_feedback_enabled", episode_query)
|
||||
|
||||
async def test_teacher_read_keeps_disabled_source_snapshot_visible(self) -> None:
|
||||
principal = _principal(Role.TEACHER)
|
||||
learner_id = uuid4()
|
||||
conn = AsyncMock()
|
||||
conn.fetchval.return_value = True
|
||||
conn.fetch.side_effect = [
|
||||
[
|
||||
{
|
||||
"prescription_record_id": uuid4(),
|
||||
"session_id": uuid4(),
|
||||
"source_learner_feedback_enabled": False,
|
||||
}
|
||||
],
|
||||
[],
|
||||
]
|
||||
conn.fetchrow.return_value = None
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs):
|
||||
yield conn
|
||||
|
||||
with patch.object(deliberate_practice_store.db, "acquire", fake_acquire):
|
||||
result = await deliberate_practice_store.read_deliberate_practice(
|
||||
principal=principal,
|
||||
learner_id=learner_id,
|
||||
)
|
||||
|
||||
self.assertEqual(len(result["prescriptions"]), 1)
|
||||
self.assertNotIn(
|
||||
"source_learner_feedback_enabled",
|
||||
result["prescriptions"][0],
|
||||
)
|
||||
|
||||
async def test_learner_read_cannot_target_another_learner(self) -> None:
|
||||
with self.assertRaises(
|
||||
deliberate_practice_store.DeliberatePracticeNotFoundError
|
||||
|
|
@ -439,6 +546,32 @@ class DeliberatePracticeStoreAsyncTests(unittest.IsolatedAsyncioTestCase):
|
|||
)
|
||||
self.assertEqual(captured.exception.status_code, 409)
|
||||
|
||||
async def test_attempt_route_maps_disabled_source_snapshot_to_403(self) -> None:
|
||||
case = self.pack.cases[0]
|
||||
body = deliberate_practices.PracticeAttemptSubmissionRequest(
|
||||
submission_id=uuid4(),
|
||||
episode=case.episodes[0],
|
||||
)
|
||||
with patch.object(
|
||||
deliberate_practices.deliberate_practice_store,
|
||||
"append_learner_attempt_submission",
|
||||
AsyncMock(
|
||||
side_effect=deliberate_practice_store.DeliberatePracticeFeedbackDisabledError(
|
||||
"source session feedback disabled"
|
||||
)
|
||||
),
|
||||
) as append:
|
||||
with self.assertRaises(HTTPException) as captured:
|
||||
await deliberate_practices.create_practice_attempt(
|
||||
prescription_id=case.episodes[0].prescription_id,
|
||||
body=body,
|
||||
principal=_principal(Role.LEARNER),
|
||||
)
|
||||
|
||||
self.assertEqual(captured.exception.status_code, 403)
|
||||
self.assertEqual(captured.exception.detail, "learner_feedback_disabled")
|
||||
append.assert_awaited_once()
|
||||
|
||||
async def test_runtime_practice_route_forwards_completed_session_identity(
|
||||
self,
|
||||
) -> None:
|
||||
|
|
|
|||
50
apps/api/app/test_engine_health_contract.py
Normal file
50
apps/api/app/test_engine_health_contract.py
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
"""실시간 내담자 공급자 준비상태의 API health 계약."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from . import main
|
||||
|
||||
|
||||
class EngineHealthContractTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_health_is_degraded_when_live_client_lane_is_unready(self) -> None:
|
||||
engine_detail = {
|
||||
"ok": False,
|
||||
"detail": "실시간 내담자 공급자 claude_cli: login required",
|
||||
"default_engine": {
|
||||
"provider": "agy_cli",
|
||||
"ok": True,
|
||||
"detail": "ready",
|
||||
},
|
||||
"live_client_engine": {
|
||||
"provider": "claude_cli",
|
||||
"ok": False,
|
||||
"detail": "login required",
|
||||
},
|
||||
}
|
||||
with (
|
||||
patch.object(main, "healthcheck", AsyncMock(return_value=True)),
|
||||
patch.object(
|
||||
main.engine_client,
|
||||
"health_detail",
|
||||
AsyncMock(return_value=engine_detail),
|
||||
),
|
||||
patch.object(main.engine_client, "engine_mode", "agy_cli"),
|
||||
patch.object(main.engine_client, "live_client_provider", "claude_cli"),
|
||||
):
|
||||
response = await main.health()
|
||||
|
||||
self.assertEqual(response["status"], "degraded")
|
||||
self.assertFalse(response["engine"])
|
||||
self.assertEqual(response["engine_mode"], "agy_cli")
|
||||
self.assertEqual(response["live_client_provider"], "claude_cli")
|
||||
self.assertEqual(
|
||||
response["live_client_engine"],
|
||||
engine_detail["live_client_engine"],
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -84,7 +84,8 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
|
|||
case_id="eval-mask-case",
|
||||
card=card,
|
||||
state=state,
|
||||
learner_text="오늘 상담에서 집중해 보겠습니다.",
|
||||
learner_text="윤찬 상담자가 서연 씨의 이야기에 집중해 보겠습니다.",
|
||||
learner_identity="윤찬",
|
||||
)
|
||||
|
||||
messages = evaluator.build_fast_messages(
|
||||
|
|
@ -96,6 +97,10 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertNotIn("김서연", blob)
|
||||
self.assertNotIn("한신대학교", blob)
|
||||
self.assertNotIn("상담심리학과", blob)
|
||||
self.assertNotIn("윤찬", blob)
|
||||
self.assertNotIn("서연 씨", blob)
|
||||
self.assertIn("[COUNSELOR]", blob)
|
||||
self.assertIn("[CLIENT]", blob)
|
||||
self.assertIn("[NAME]", blob)
|
||||
self.assertIn("[ORG]", blob)
|
||||
|
||||
|
|
@ -314,6 +319,31 @@ class EvaluationPersistenceMappingTest(unittest.IsolatedAsyncioTestCase):
|
|||
for masked in ("[NAME]", "[ORG]", "[PHONE]"):
|
||||
self.assertIn(masked, blob)
|
||||
|
||||
def test_session_evaluation_write_role_tokenizes_known_identities(self) -> None:
|
||||
result = evaluator.SessionEvaluation(
|
||||
session_id="session-role-privacy",
|
||||
stage="정리",
|
||||
scope="session_end",
|
||||
turns_evaluated=2,
|
||||
strengths=["윤찬 상담자가 서연의 마음을 반영했다."],
|
||||
improvements=["서연 씨의 반응을 윤찬이 한 번 더 기다린다."],
|
||||
supervisor_rationale="윤찬과 서연의 상호작용을 확인했다.",
|
||||
)
|
||||
|
||||
write = session_persistence.SessionEvaluationWrite.from_result(
|
||||
session_id="session-role-privacy",
|
||||
learner_id="learner-role-privacy",
|
||||
result=result,
|
||||
counselor_identity="윤찬",
|
||||
client_identity="서연(가명) · 고2 · 우울/자살사고",
|
||||
)
|
||||
blob = json.dumps(write.payload, ensure_ascii=False)
|
||||
|
||||
self.assertNotIn("윤찬", blob)
|
||||
self.assertNotIn("서연", blob)
|
||||
self.assertIn("[COUNSELOR]", blob)
|
||||
self.assertIn("[CLIENT]", blob)
|
||||
|
||||
def test_session_evaluation_write_from_error_preserves_fallback_shape(self) -> None:
|
||||
write = session_persistence.SessionEvaluationWrite.from_error(
|
||||
session_id="session-1",
|
||||
|
|
|
|||
642
apps/api/app/test_feedback_policy.py
Normal file
642
apps/api/app/test_feedback_policy.py
Normal file
|
|
@ -0,0 +1,642 @@
|
|||
"""계정별 학습자 AI 피드백 정책 회귀 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .deps import Principal, Role
|
||||
from . import session_persistence
|
||||
from .routes import admin as admin_routes
|
||||
from .routes import (
|
||||
calibration_transfer,
|
||||
deliberate_practices,
|
||||
measurements,
|
||||
multimodal_alliance,
|
||||
outcome_trajectories,
|
||||
rupture_repairs,
|
||||
sessions,
|
||||
)
|
||||
from .services import deliberate_practice_store, feedback_policy, state_machine
|
||||
from .services.persona import P1
|
||||
from .session_read_model import SessionReviewReadInput, build_session_review
|
||||
from .store import InProcSession, TurnRecord
|
||||
|
||||
|
||||
def _session(*, learner_feedback_enabled: bool) -> InProcSession:
|
||||
state = state_machine.init_state(params=P1.openness_params())
|
||||
return InProcSession(
|
||||
session_id="00000000-0000-4000-8000-000000000401",
|
||||
case_id="feedback-policy-case",
|
||||
learner_id="00000000-0000-0000-0000-000000000401",
|
||||
persona_code=P1.code,
|
||||
theory_mode="humanistic",
|
||||
persona=P1,
|
||||
state=state,
|
||||
created_at=1_000.0,
|
||||
ended_at=1_120.0,
|
||||
ended=True,
|
||||
learner_feedback_enabled=learner_feedback_enabled,
|
||||
turns=[
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="counselor",
|
||||
stage=state.stage.value,
|
||||
text="상담자 발화",
|
||||
text_masked="상담자 발화",
|
||||
evaluation={
|
||||
"appropriateness": "pos",
|
||||
"appropriateness_note": "AI 학습 피드백",
|
||||
"techniques": [{"label": "반영"}],
|
||||
},
|
||||
),
|
||||
TurnRecord(
|
||||
turn_seq=2,
|
||||
speaker="client",
|
||||
stage=state.stage.value,
|
||||
text="가상 내담자 응답",
|
||||
text_masked="가상 내담자 응답",
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class FeedbackPolicyTest(unittest.TestCase):
|
||||
def test_admin_contract_defaults_existing_and_new_accounts_to_enabled(self) -> None:
|
||||
created = admin_routes.AdminUserCreate(
|
||||
email="feedback-policy@hs.ac.kr",
|
||||
display_name="피드백 정책 대상",
|
||||
)
|
||||
patched = admin_routes.AdminUserPatch(learner_feedback_enabled=False)
|
||||
|
||||
self.assertTrue(created.learner_feedback_enabled)
|
||||
self.assertFalse(patched.learner_feedback_enabled)
|
||||
|
||||
def test_only_learner_viewer_is_blocked(self) -> None:
|
||||
sess = _session(learner_feedback_enabled=False)
|
||||
|
||||
self.assertFalse(feedback_policy.learner_feedback_enabled(sess))
|
||||
self.assertFalse(
|
||||
feedback_policy.can_expose_learner_feedback(
|
||||
sess,
|
||||
viewer_role="learner",
|
||||
)
|
||||
)
|
||||
self.assertTrue(
|
||||
feedback_policy.can_expose_learner_feedback(
|
||||
sess,
|
||||
viewer_role="teacher",
|
||||
)
|
||||
)
|
||||
|
||||
def test_current_account_off_overrides_past_enabled_session_snapshot(self) -> None:
|
||||
sess = _session(learner_feedback_enabled=True)
|
||||
principal = Principal(
|
||||
user_id=sess.learner_id,
|
||||
role=Role.LEARNER,
|
||||
learner_feedback_enabled=False,
|
||||
)
|
||||
|
||||
self.assertFalse(
|
||||
feedback_policy.effective_learner_feedback_enabled(sess, principal)
|
||||
)
|
||||
self.assertFalse(
|
||||
feedback_policy.can_expose_principal_learner_feedback(sess, principal)
|
||||
)
|
||||
self.assertTrue(
|
||||
feedback_policy.can_expose_learner_feedback(
|
||||
sess,
|
||||
viewer_role="admin",
|
||||
)
|
||||
)
|
||||
|
||||
def test_disabled_review_preserves_transcript_but_removes_feedback(self) -> None:
|
||||
sess = _session(learner_feedback_enabled=False)
|
||||
|
||||
review = build_session_review(
|
||||
SessionReviewReadInput(
|
||||
session=sess,
|
||||
evaluation_record={"status": "ready", "payload": {"summary": "AI 요약"}},
|
||||
evaluation_durable=True,
|
||||
learner_feedback_enabled=False,
|
||||
expose_learner_feedback=False,
|
||||
now_ts=1_120.0,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertFalse(review.learnerFeedbackEnabled)
|
||||
self.assertFalse(review.reviewReady)
|
||||
self.assertEqual(review.supervisorState, "피드백 비활성")
|
||||
self.assertIn("AI 학습 피드백을 비활성화", review.summary)
|
||||
self.assertEqual([turn.text for turn in review.turns], ["상담자 발화", "가상 내담자 응답"])
|
||||
self.assertTrue(all(turn.note is None for turn in review.turns))
|
||||
self.assertTrue(all(not turn.techniques for turn in review.turns))
|
||||
self.assertTrue(all(not turn.nonverbal for turn in review.turns))
|
||||
self.assertEqual(review.rubric, [])
|
||||
self.assertEqual(review.goodMoments, [])
|
||||
self.assertEqual(review.growthPoints, [])
|
||||
self.assertIsNone(review.nextLine)
|
||||
self.assertIsNone(review.clientFeedback)
|
||||
self.assertEqual(review.caseWorksheet.status, "empty")
|
||||
|
||||
def test_disabled_review_preserves_saved_learner_worksheet_verbatim(self) -> None:
|
||||
sess = _session(learner_feedback_enabled=False)
|
||||
saved = {
|
||||
"status": "saved_by_learner",
|
||||
"generatedBy": "learner",
|
||||
"sections": [
|
||||
{
|
||||
"key": "learner-notes",
|
||||
"title": "내 기록",
|
||||
"items": [
|
||||
{
|
||||
"key": "presenting-complaint",
|
||||
"label": "주호소",
|
||||
"value": "학습자가 직접 저장한 원문",
|
||||
"evidence": [],
|
||||
"confidence": "none",
|
||||
}
|
||||
],
|
||||
}
|
||||
],
|
||||
"limitations": ["학습자 작성"],
|
||||
"savedAt": "2026-08-27T01:02:03Z",
|
||||
}
|
||||
|
||||
review = build_session_review(
|
||||
SessionReviewReadInput(
|
||||
session=sess,
|
||||
saved_worksheet_payload=saved,
|
||||
learner_feedback_enabled=False,
|
||||
expose_learner_feedback=False,
|
||||
now_ts=1_120.0,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertEqual(review.caseWorksheet.status, "saved_by_learner")
|
||||
self.assertEqual(
|
||||
review.caseWorksheet.sections[0].items[0].value,
|
||||
"학습자가 직접 저장한 원문",
|
||||
)
|
||||
self.assertEqual(review.caseWorksheet.limitations, ["학습자 작성"])
|
||||
|
||||
def test_calibration_redaction_keeps_only_learner_authored_prediction(self) -> None:
|
||||
payload = {
|
||||
"prediction_histories": [
|
||||
{
|
||||
"history_id": "history-1",
|
||||
"revisions": [{"revision_reason": "학습자 입력"}],
|
||||
"lock": {"lock_id": "lock-1"},
|
||||
"external_observation": {"status": "passed"},
|
||||
}
|
||||
],
|
||||
"calibration_assessments": [{"assessment": "ai"}],
|
||||
"transfer_suites": [{"suite": "ai"}],
|
||||
"teacher_reviews": [{"review": "teacher"}],
|
||||
"actual_executions": [{"execution": "ai"}],
|
||||
"actual_transfer_assessments": [{"assessment": "ai"}],
|
||||
}
|
||||
|
||||
redacted = calibration_transfer._learner_input_only_payload(payload)
|
||||
|
||||
self.assertEqual(
|
||||
redacted["prediction_histories"][0]["revisions"],
|
||||
[{"revision_reason": "학습자 입력"}],
|
||||
)
|
||||
self.assertEqual(
|
||||
redacted["prediction_histories"][0]["lock"],
|
||||
{"lock_id": "lock-1"},
|
||||
)
|
||||
self.assertIsNone(
|
||||
redacted["prediction_histories"][0]["external_observation"]
|
||||
)
|
||||
for key in (
|
||||
"calibration_assessments",
|
||||
"transfer_suites",
|
||||
"teacher_reviews",
|
||||
"actual_executions",
|
||||
"actual_transfer_assessments",
|
||||
):
|
||||
self.assertEqual(redacted[key], [])
|
||||
|
||||
|
||||
class FeedbackPolicyRouteTest(unittest.IsolatedAsyncioTestCase):
|
||||
@staticmethod
|
||||
def _principal(*, enabled: bool, role: Role = Role.LEARNER) -> Principal:
|
||||
return Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000401",
|
||||
role=role,
|
||||
learner_feedback_enabled=enabled,
|
||||
)
|
||||
|
||||
async def test_session_derived_routes_return_403_before_store_read(self) -> None:
|
||||
principal = self._principal(enabled=True)
|
||||
sess = _session(learner_feedback_enabled=False)
|
||||
session_id = UUID(sess.session_id)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"load_session",
|
||||
AsyncMock(return_value=sess),
|
||||
),
|
||||
patch.object(
|
||||
rupture_repairs.rupture_repair_store,
|
||||
"read_rupture_repairs",
|
||||
AsyncMock(),
|
||||
) as rupture_read,
|
||||
patch.object(
|
||||
outcome_trajectories.outcome_trajectory_store,
|
||||
"read_outcome_trajectory",
|
||||
AsyncMock(),
|
||||
) as outcome_read,
|
||||
):
|
||||
for call in (
|
||||
lambda: rupture_repairs.get_rupture_repairs(session_id, principal),
|
||||
lambda: outcome_trajectories.get_outcome_trajectory(
|
||||
session_id,
|
||||
principal,
|
||||
),
|
||||
lambda: outcome_trajectories.recompute_outcome_trajectory(
|
||||
session_id,
|
||||
outcome_trajectories.OutcomeTrajectoryRecomputeRequest(),
|
||||
principal,
|
||||
),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await call()
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
self.assertEqual(
|
||||
caught.exception.detail,
|
||||
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
|
||||
rupture_read.assert_not_awaited()
|
||||
outcome_read.assert_not_awaited()
|
||||
|
||||
async def test_current_off_redacts_past_enabled_review_without_eval_read(self) -> None:
|
||||
principal = self._principal(enabled=False)
|
||||
sess = _session(learner_feedback_enabled=True)
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_review_session_or_404",
|
||||
AsyncMock(return_value=(sess, principal)),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"load_session_evaluation",
|
||||
AsyncMock(),
|
||||
) as evaluation_read,
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"load_case_worksheet",
|
||||
AsyncMock(return_value=(None, True)),
|
||||
),
|
||||
):
|
||||
review = await sessions.get_session_review(sess.session_id, principal)
|
||||
|
||||
evaluation_read.assert_not_awaited()
|
||||
self.assertFalse(review.learnerFeedbackEnabled)
|
||||
self.assertFalse(review.reviewReady)
|
||||
self.assertEqual([turn.text for turn in review.turns], ["상담자 발화", "가상 내담자 응답"])
|
||||
self.assertTrue(all(turn.note is None for turn in review.turns))
|
||||
|
||||
async def test_current_off_marks_past_enabled_session_detail_input_only(self) -> None:
|
||||
principal = self._principal(enabled=False)
|
||||
sess = _session(learner_feedback_enabled=True)
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=sess),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"load_session_evaluation",
|
||||
AsyncMock(),
|
||||
) as evaluation_read,
|
||||
):
|
||||
detail = await sessions.get_session_detail(sess.session_id, principal)
|
||||
|
||||
evaluation_read.assert_not_awaited()
|
||||
self.assertFalse(detail.learner_feedback_enabled)
|
||||
self.assertFalse(detail.review_ready)
|
||||
self.assertEqual([turn.text for turn in detail.turns], ["상담자 발화", "가상 내담자 응답"])
|
||||
|
||||
async def test_deliberate_practice_read_returns_403_before_store_read(self) -> None:
|
||||
principal = self._principal(enabled=False)
|
||||
with patch.object(
|
||||
deliberate_practices.deliberate_practice_store,
|
||||
"read_deliberate_practice",
|
||||
AsyncMock(),
|
||||
) as read:
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await deliberate_practices.get_my_deliberate_practice(principal)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
self.assertEqual(
|
||||
caught.exception.detail,
|
||||
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
read.assert_not_awaited()
|
||||
|
||||
async def test_deliberate_practice_read_rejects_disabled_source_snapshot(self) -> None:
|
||||
principal = self._principal(enabled=True)
|
||||
with patch.object(
|
||||
deliberate_practices.deliberate_practice_store,
|
||||
"read_deliberate_practice",
|
||||
AsyncMock(
|
||||
side_effect=deliberate_practice_store.DeliberatePracticeFeedbackDisabledError(
|
||||
"source session feedback disabled"
|
||||
)
|
||||
),
|
||||
) as read:
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await deliberate_practices.get_my_deliberate_practice(principal)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
self.assertEqual(
|
||||
caught.exception.detail,
|
||||
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
read.assert_awaited_once_with(principal=principal)
|
||||
|
||||
async def test_calibration_read_redacts_derived_fields_at_route_boundary(self) -> None:
|
||||
principal = self._principal(enabled=False)
|
||||
payload = {
|
||||
"learner_id": UUID(principal.user_id),
|
||||
"requested_view": "learner",
|
||||
"clinical_claim_allowed": False,
|
||||
"prediction_histories": [],
|
||||
"calibration_assessments": [{"private": "ai"}],
|
||||
"transfer_suites": [{"private": "ai"}],
|
||||
"teacher_reviews": [{"private": "teacher"}],
|
||||
"actual_executions": [{"private": "ai"}],
|
||||
"actual_transfer_assessments": [{"private": "ai"}],
|
||||
}
|
||||
with patch.object(
|
||||
calibration_transfer.calibration_transfer_store,
|
||||
"read_calibration_transfer",
|
||||
AsyncMock(return_value=payload),
|
||||
) as read:
|
||||
response = await calibration_transfer.get_my_calibration_transfer(
|
||||
principal,
|
||||
)
|
||||
|
||||
read.assert_awaited_once()
|
||||
self.assertEqual(response.prediction_histories, [])
|
||||
self.assertEqual(response.calibration_assessments, [])
|
||||
self.assertEqual(response.transfer_suites, [])
|
||||
self.assertEqual(response.teacher_reviews, [])
|
||||
self.assertEqual(response.actual_executions, [])
|
||||
self.assertEqual(response.actual_transfer_assessments, [])
|
||||
|
||||
async def test_calibration_aggregate_current_on_redacts_past_disabled_snapshot(
|
||||
self,
|
||||
) -> None:
|
||||
principal = self._principal(enabled=True)
|
||||
payload = {
|
||||
"learner_id": UUID(principal.user_id),
|
||||
"requested_view": "learner",
|
||||
"clinical_claim_allowed": False,
|
||||
"_learner_feedback_snapshot_enabled": False,
|
||||
"prediction_histories": [],
|
||||
"calibration_assessments": [{"private": "past-session-ai"}],
|
||||
"transfer_suites": [{"private": "past-session-ai"}],
|
||||
"teacher_reviews": [{"private": "teacher"}],
|
||||
"actual_executions": [{"private": "past-session-ai"}],
|
||||
"actual_transfer_assessments": [{"private": "past-session-ai"}],
|
||||
}
|
||||
with patch.object(
|
||||
calibration_transfer.calibration_transfer_store,
|
||||
"read_calibration_transfer",
|
||||
AsyncMock(return_value=payload),
|
||||
) as read:
|
||||
response = await calibration_transfer.get_my_calibration_transfer(
|
||||
principal,
|
||||
)
|
||||
|
||||
read.assert_awaited_once_with(principal=principal)
|
||||
self.assertEqual(response.prediction_histories, [])
|
||||
self.assertEqual(response.calibration_assessments, [])
|
||||
self.assertEqual(response.transfer_suites, [])
|
||||
self.assertEqual(response.teacher_reviews, [])
|
||||
self.assertEqual(response.actual_executions, [])
|
||||
self.assertEqual(response.actual_transfer_assessments, [])
|
||||
|
||||
async def test_actual_execution_input_is_saved_but_ai_result_is_redacted(self) -> None:
|
||||
principal = self._principal(enabled=False)
|
||||
body = calibration_transfer.ActualTransferExecutionRequest(
|
||||
original_transfer_trial_record_id=UUID(
|
||||
"00000000-0000-4000-8000-000000000411"
|
||||
),
|
||||
practice_session_id=UUID("00000000-0000-4000-8000-000000000412"),
|
||||
)
|
||||
with patch.object(
|
||||
calibration_transfer.calibration_transfer_store,
|
||||
"append_actual_transfer_execution",
|
||||
AsyncMock(
|
||||
return_value={
|
||||
"execution": {"private": "ai"},
|
||||
"assessment": {"private": "ai"},
|
||||
"idempotent_replay": False,
|
||||
}
|
||||
),
|
||||
) as append:
|
||||
response = await calibration_transfer.create_actual_transfer_execution(
|
||||
body,
|
||||
principal,
|
||||
)
|
||||
|
||||
append.assert_awaited_once_with(
|
||||
principal=principal,
|
||||
**body.model_dump(),
|
||||
)
|
||||
self.assertIsNone(response.execution)
|
||||
self.assertIsNone(response.assessment)
|
||||
self.assertFalse(response.idempotent_replay)
|
||||
|
||||
async def test_multimodal_derived_read_is_403_but_privacy_read_is_redacted(self) -> None:
|
||||
principal = self._principal(enabled=True)
|
||||
sess = _session(learner_feedback_enabled=False)
|
||||
session_id = UUID(sess.session_id)
|
||||
payload = {
|
||||
"session_id": session_id,
|
||||
"learner_id": UUID(principal.user_id),
|
||||
"clinical_claim_allowed": False,
|
||||
"consent_snapshots": [{"consent_status": "granted"}],
|
||||
"timelines": [{"timeline_id": "derived"}],
|
||||
"word_timestamps": [{"word_index": 1}],
|
||||
"voice_events": [{"event_id": "derived"}],
|
||||
"measurements": [{"axis": "bond"}],
|
||||
"fusion_decisions": [{"axis": "bond"}],
|
||||
"deletion_requests": [{"status": "requested"}],
|
||||
}
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"load_session",
|
||||
AsyncMock(return_value=sess),
|
||||
),
|
||||
patch.object(
|
||||
multimodal_alliance.multimodal_alliance_store,
|
||||
"read_session_metadata",
|
||||
AsyncMock(return_value=payload),
|
||||
) as read,
|
||||
):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await multimodal_alliance.get_multimodal_session_metadata(
|
||||
session_id,
|
||||
principal,
|
||||
True,
|
||||
)
|
||||
privacy = await multimodal_alliance.get_multimodal_session_metadata(
|
||||
session_id,
|
||||
principal,
|
||||
False,
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
self.assertEqual(caught.exception.detail, "learner_feedback_disabled")
|
||||
read.assert_awaited_once()
|
||||
self.assertEqual(privacy.consent_snapshots, payload["consent_snapshots"])
|
||||
self.assertEqual(privacy.deletion_requests, payload["deletion_requests"])
|
||||
self.assertEqual(privacy.timelines, [])
|
||||
self.assertEqual(privacy.word_timestamps, [])
|
||||
self.assertEqual(privacy.voice_events, [])
|
||||
self.assertEqual(privacy.measurements, [])
|
||||
self.assertEqual(privacy.fusion_decisions, [])
|
||||
|
||||
async def test_alliance_read_keeps_self_scores_but_redacts_measurements(self) -> None:
|
||||
principal = self._principal(enabled=False)
|
||||
session_id = UUID("00000000-0000-4000-8000-000000000421")
|
||||
payload = {
|
||||
"pulse_id": UUID("00000000-0000-4000-8000-000000000422"),
|
||||
"checkpoint": "post",
|
||||
"status": "ready",
|
||||
"learner_locked_at": datetime.now(timezone.utc),
|
||||
"revealed_at": datetime.now(timezone.utc),
|
||||
"error_code": None,
|
||||
"self_scores": {"goal": 0.8, "task": 0.6, "bond": 1.0},
|
||||
"measurements": [{"private": "ai rationale and evidence"}],
|
||||
}
|
||||
with patch.object(
|
||||
measurements.alliance_measurement,
|
||||
"list_alliance_pulses",
|
||||
AsyncMock(return_value=[payload]),
|
||||
) as read:
|
||||
response = await measurements.get_alliance_pulses(
|
||||
session_id,
|
||||
principal,
|
||||
)
|
||||
|
||||
read.assert_awaited_once_with(principal=principal, session_id=session_id)
|
||||
self.assertEqual(response.items[0].self_scores.model_dump(), payload["self_scores"])
|
||||
self.assertEqual(response.items[0].measurements, [])
|
||||
|
||||
async def test_teacher_multimodal_view_keeps_derived_output(self) -> None:
|
||||
principal = self._principal(enabled=False, role=Role.TEACHER)
|
||||
session_id = UUID("00000000-0000-4000-8000-000000000401")
|
||||
payload = {
|
||||
"session_id": session_id,
|
||||
"learner_id": UUID("00000000-0000-0000-0000-000000000401"),
|
||||
"clinical_claim_allowed": False,
|
||||
"consent_snapshots": [],
|
||||
"timelines": [{"timeline_id": "teacher-visible"}],
|
||||
"word_timestamps": [],
|
||||
"voice_events": [],
|
||||
"measurements": [],
|
||||
"fusion_decisions": [],
|
||||
"deletion_requests": [],
|
||||
}
|
||||
with patch.object(
|
||||
multimodal_alliance.multimodal_alliance_store,
|
||||
"read_session_metadata",
|
||||
AsyncMock(return_value=payload),
|
||||
):
|
||||
response = await multimodal_alliance.get_multimodal_session_metadata(
|
||||
session_id,
|
||||
principal,
|
||||
True,
|
||||
)
|
||||
|
||||
self.assertEqual(response.timelines, payload["timelines"])
|
||||
|
||||
async def test_dashboard_keeps_session_counts_but_hides_feedback_metrics(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000401",
|
||||
role=Role.LEARNER,
|
||||
learner_feedback_enabled=False,
|
||||
)
|
||||
sess = _session(learner_feedback_enabled=True)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"list_recent_sessions",
|
||||
AsyncMock(return_value=([sess], True)),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"load_session_evaluation",
|
||||
AsyncMock(return_value=({"status": "ready"}, True)),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"list_session_archives",
|
||||
AsyncMock(return_value=({}, True)),
|
||||
),
|
||||
):
|
||||
dashboard = await sessions.learner_dashboard(principal)
|
||||
|
||||
self.assertEqual(dashboard.overview.total_sessions, 1)
|
||||
self.assertEqual(dashboard.overview.completed_sessions, 1)
|
||||
self.assertEqual(dashboard.overview.review_ready_sessions, 0)
|
||||
self.assertEqual(dashboard.growth.evaluated_sessions, 0)
|
||||
self.assertEqual(dashboard.recent_feedback, [])
|
||||
self.assertEqual(dashboard.persona_progress[0].sessions, 1)
|
||||
self.assertIsNone(dashboard.persona_progress[0].latest_score)
|
||||
|
||||
async def test_live_coach_is_blocked_before_generation(self) -> None:
|
||||
principal = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000401",
|
||||
role=Role.LEARNER,
|
||||
learner_feedback_enabled=False,
|
||||
)
|
||||
sess = _session(learner_feedback_enabled=True)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=sess),
|
||||
),
|
||||
patch.object(
|
||||
sessions.live_coach,
|
||||
"generate_live_coaching",
|
||||
AsyncMock(),
|
||||
) as generate,
|
||||
):
|
||||
with self.assertRaises(HTTPException) as caught:
|
||||
await sessions.live_coach_turn(
|
||||
sess.session_id,
|
||||
sessions.LiveCoachRequest(learner_text="도와줘"),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 403)
|
||||
self.assertEqual(
|
||||
caught.exception.detail,
|
||||
feedback_policy.LEARNER_FEEDBACK_DISABLED_DETAIL,
|
||||
)
|
||||
generate.assert_not_awaited()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
74
apps/api/app/test_first_session_checklist.py
Normal file
74
apps/api/app/test_first_session_checklist.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
"""첫 회기 라포·개방질문 체크리스트의 결정론 계약 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
|
||||
from .session_read_model import ReviewTurn, first_session_checklist
|
||||
|
||||
|
||||
def _turn(turn_id: str, text: str, *, speaker: str = "learner") -> ReviewTurn:
|
||||
return ReviewTurn(
|
||||
id=turn_id,
|
||||
ts="0:00",
|
||||
speaker=speaker,
|
||||
who="상담자" if speaker == "learner" else "내담자",
|
||||
text=text,
|
||||
)
|
||||
|
||||
|
||||
class FirstSessionChecklistTest(unittest.TestCase):
|
||||
def test_first_session_links_versioned_criteria_to_evidence_turns(self) -> None:
|
||||
checklist = first_session_checklist(
|
||||
session_no=1,
|
||||
turns=[
|
||||
_turn(
|
||||
"t1",
|
||||
"그동안 혼자 버티느라 많이 힘들게 느껴졌겠어요. 어떤 순간이 가장 어려웠나요?",
|
||||
),
|
||||
_turn("t2", "그냥 계속 힘들었어요.", speaker="client"),
|
||||
],
|
||||
)
|
||||
|
||||
self.assertEqual(checklist.version, "first-session-rapport-open-question.v1")
|
||||
self.assertTrue(checklist.applicable)
|
||||
self.assertEqual(
|
||||
[criterion.criterionId for criterion in checklist.criteria],
|
||||
[
|
||||
"first-session.rapport-reflection",
|
||||
"first-session.open-question-one-focus",
|
||||
],
|
||||
)
|
||||
self.assertEqual(
|
||||
[criterion.status for criterion in checklist.criteria],
|
||||
["met", "met"],
|
||||
)
|
||||
self.assertEqual(checklist.criteria[0].evidenceTurns[0].turnId, "t1")
|
||||
self.assertEqual(checklist.criteria[0].evidenceTurns[0].turnSeq, 1)
|
||||
|
||||
def test_first_session_keeps_missing_behavior_visible_without_scoring(self) -> None:
|
||||
checklist = first_session_checklist(
|
||||
session_no=1,
|
||||
turns=[_turn("t1", "네, 알겠습니다. 그렇게 하세요.")],
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
[criterion.status for criterion in checklist.criteria],
|
||||
["not_observed", "not_observed"],
|
||||
)
|
||||
self.assertTrue(all(not item.evidenceTurns for item in checklist.criteria))
|
||||
self.assertIn("임상 평가나 성적 판정이 아닙니다", checklist.note)
|
||||
|
||||
def test_later_session_is_explicitly_not_applicable(self) -> None:
|
||||
checklist = first_session_checklist(
|
||||
session_no=2,
|
||||
turns=[_turn("t1", "어떤 마음이 들었나요?")],
|
||||
)
|
||||
|
||||
self.assertFalse(checklist.applicable)
|
||||
self.assertEqual(checklist.status, "not_applicable")
|
||||
self.assertEqual(checklist.criteria, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -6,9 +6,10 @@ import unittest
|
|||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from .deps import Principal, Role
|
||||
from . import session_read_model
|
||||
from .routes import sessions
|
||||
from .services import state_machine
|
||||
from .services.persona import P1, P2
|
||||
from .services.persona import P1, P2, P3
|
||||
from .store import InProcSession, TurnRecord
|
||||
|
||||
|
||||
|
|
@ -30,7 +31,7 @@ def _session(
|
|||
created_at: float,
|
||||
ended: bool = True,
|
||||
) -> InProcSession:
|
||||
persona = P2 if persona_code == P2.code else P1
|
||||
persona = {P1.code: P1, P2.code: P2, P3.code: P3}.get(persona_code, P1)
|
||||
state = state_machine.init_state(params=persona.openness_params())
|
||||
state.stage = state_machine.Stage.EXPLORE
|
||||
return InProcSession(
|
||||
|
|
@ -70,6 +71,64 @@ def _session(
|
|||
)
|
||||
|
||||
|
||||
def _exposure_sessions(persona_codes: list[str]) -> list[InProcSession]:
|
||||
return [
|
||||
_session(
|
||||
session_id=f"exposure-{index}",
|
||||
session_no=index,
|
||||
persona_code=persona_code,
|
||||
score="neutral",
|
||||
rapport=0.1,
|
||||
technique="reflection",
|
||||
created_at=float(index),
|
||||
)
|
||||
for index, persona_code in enumerate(persona_codes, start=1)
|
||||
]
|
||||
|
||||
|
||||
class TrainingExposureMetricTest(unittest.TestCase):
|
||||
def test_zero_completed_sessions_is_insufficient(self) -> None:
|
||||
metric = session_read_model.dashboard_training_exposure([])
|
||||
|
||||
self.assertEqual(metric.label, "판정 근거 부족")
|
||||
self.assertEqual(metric.completed_sessions, 0)
|
||||
self.assertIsNone(metric.dominant_share)
|
||||
|
||||
def test_one_completed_session_is_insufficient(self) -> None:
|
||||
metric = session_read_model.dashboard_training_exposure(
|
||||
_exposure_sessions([P1.code])
|
||||
)
|
||||
|
||||
self.assertEqual(metric.label, "판정 근거 부족")
|
||||
self.assertEqual(metric.dominant_share, 1.0)
|
||||
|
||||
def test_four_sessions_with_broad_exposure_is_balanced(self) -> None:
|
||||
metric = session_read_model.dashboard_training_exposure(
|
||||
_exposure_sessions([P1.code, P1.code, P2.code, P3.code])
|
||||
)
|
||||
|
||||
self.assertEqual(metric.label, "균형")
|
||||
self.assertEqual(metric.dominant_share, 0.5)
|
||||
|
||||
def test_dominant_share_at_seventy_five_percent_is_attention(self) -> None:
|
||||
metric = session_read_model.dashboard_training_exposure(
|
||||
_exposure_sessions([P1.code, P1.code, P1.code, P2.code])
|
||||
)
|
||||
|
||||
self.assertEqual(metric.label, "훈련 집중 주의")
|
||||
self.assertEqual(metric.dominant_sessions, 3)
|
||||
self.assertEqual(metric.dominant_share, 0.75)
|
||||
|
||||
def test_tied_dominant_persona_is_deterministic_and_balanced(self) -> None:
|
||||
metric = session_read_model.dashboard_training_exposure(
|
||||
_exposure_sessions([P2.code, P1.code, P2.code, P1.code])
|
||||
)
|
||||
|
||||
self.assertEqual(metric.label, "균형")
|
||||
self.assertEqual(metric.dominant_persona_code, P1.code)
|
||||
self.assertEqual(metric.dominant_share, 0.5)
|
||||
|
||||
|
||||
class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_dashboard_returns_personalized_growth_from_owned_sessions(self) -> None:
|
||||
principal = _principal()
|
||||
|
|
@ -150,6 +209,8 @@ class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual([row.persona_code for row in response.persona_progress], [P2.code, P1.code])
|
||||
self.assertEqual(response.persona_progress[0].review_ready_sessions, 1)
|
||||
self.assertEqual(response.persona_progress[1].sessions, 2)
|
||||
self.assertEqual(response.training_exposure.label, "판정 근거 부족")
|
||||
self.assertEqual(response.training_exposure.completed_sessions, 2)
|
||||
self.assertEqual(response.achievements[0].state, "done")
|
||||
self.assertEqual(response.achievements[1].state, "done")
|
||||
self.assertEqual(len(response.recent_feedback), 3)
|
||||
|
|
|
|||
|
|
@ -27,6 +27,89 @@ class _SourcePackConn:
|
|||
|
||||
|
||||
class LiveCoachSourcePackTest(unittest.TestCase):
|
||||
def test_local_grounding_preserves_each_source_license_policy(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_root:
|
||||
root = Path(temp_root)
|
||||
workbook_path = root / "live_coaching_workbook_0615.json"
|
||||
source_dir = root / "live_coaching_sources"
|
||||
source_dir.mkdir()
|
||||
|
||||
def write_pack(
|
||||
path: Path,
|
||||
*,
|
||||
source_id: str,
|
||||
license_class: str,
|
||||
external_llm_ok: bool,
|
||||
) -> None:
|
||||
path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"source": {
|
||||
"source_id": source_id,
|
||||
"title": source_id,
|
||||
"kb_kind": "theory",
|
||||
"license_class": license_class,
|
||||
"external_llm_ok": external_llm_ok,
|
||||
},
|
||||
"chunks": [
|
||||
{
|
||||
"id": f"{source_id}-chunk",
|
||||
"summary": f"{source_id} policy guidance",
|
||||
"keywords": ["policy"],
|
||||
}
|
||||
],
|
||||
},
|
||||
ensure_ascii=False,
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
write_pack(
|
||||
workbook_path,
|
||||
source_id="allow-a",
|
||||
license_class="A",
|
||||
external_llm_ok=True,
|
||||
)
|
||||
for filename, source_id, license_class, external_llm_ok in (
|
||||
("allow-b.json", "allow-b", "B", True),
|
||||
("block-c.json", "block-c", "C", True),
|
||||
("block-d.json", "block-d", "D", True),
|
||||
("block-false.json", "block-false", "A", False),
|
||||
):
|
||||
write_pack(
|
||||
source_dir / filename,
|
||||
source_id=source_id,
|
||||
license_class=license_class,
|
||||
external_llm_ok=external_llm_ok,
|
||||
)
|
||||
|
||||
item = live_coach.LiveCoachInput(
|
||||
session_id="source-policy-session",
|
||||
turn_seq=1,
|
||||
stage="exploration",
|
||||
effective_openness=0.5,
|
||||
theory_mode="humanistic",
|
||||
persona_code="P1",
|
||||
persona_name="서연",
|
||||
learner_text="policy",
|
||||
)
|
||||
with (
|
||||
patch.object(live_coach, "_REPO_ROOT", root),
|
||||
patch.object(live_coach, "_WORKBOOK_PATH", workbook_path),
|
||||
patch.object(live_coach, "_SOURCE_DIR", source_dir),
|
||||
):
|
||||
live_coach.clear_local_source_pack_cache()
|
||||
try:
|
||||
grounding = live_coach._local_reference_grounding(item)
|
||||
finally:
|
||||
live_coach.clear_local_source_pack_cache()
|
||||
|
||||
by_source = {entry.source_id: entry for entry in grounding}
|
||||
self.assertEqual(set(by_source), {"allow-a", "allow-b"})
|
||||
self.assertEqual(by_source["allow-a"].license_class, "A")
|
||||
self.assertEqual(by_source["allow-b"].license_class, "B")
|
||||
self.assertTrue(all(entry.external_llm_ok for entry in grounding))
|
||||
|
||||
def test_manifest_refresh_reads_changed_repo_source_pack_after_cache_warm(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as temp_root:
|
||||
root = Path(temp_root)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,14 @@ from __future__ import annotations
|
|||
|
||||
import importlib.util
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from . import persona_generation_contract, persona_repository, session_persistence
|
||||
from . import persona_generation_contract, persona_read_model, persona_repository, session_persistence
|
||||
from .deps import Principal, Role
|
||||
from .engine_client import GenerateResponse
|
||||
from .persona_repository import PersonaDraftRecord, PersonaReviewItem
|
||||
|
|
@ -247,6 +248,28 @@ def _draft_payload(
|
|||
|
||||
|
||||
class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
|
||||
def test_catalog_summary_prefers_complaint_over_surface_condition(self) -> None:
|
||||
card = replace(
|
||||
persona_service.SEED_PERSONAS["P1"],
|
||||
presenting={
|
||||
"surface": "상담자가 해결책을 서두르지 않을 때 깊은 이야기가 드러남",
|
||||
"complaint": "학교에 가기 싫고 모든 일이 무의미하게 느껴짐",
|
||||
},
|
||||
)
|
||||
entry = persona_repository.CatalogPersona(
|
||||
card=card,
|
||||
persona_id="00000000-0000-0000-0000-000000000031",
|
||||
version=2,
|
||||
source="database",
|
||||
)
|
||||
|
||||
summary = persona_read_model.persona_summary(entry)
|
||||
|
||||
self.assertEqual(
|
||||
summary.presenting_summary,
|
||||
"학교에 가기 싫고 모든 일이 무의미하게 느껴짐",
|
||||
)
|
||||
|
||||
def test_persona_seed_runner_reports_repo_manifest_without_db(self) -> None:
|
||||
script_path = Path(__file__).resolve().parents[3] / "scripts" / "materialize-persona-seeds.py"
|
||||
spec = importlib.util.spec_from_file_location("materialize_persona_seeds", script_path)
|
||||
|
|
|
|||
314
apps/api/app/test_persona_session_contract.py
Normal file
314
apps/api/app/test_persona_session_contract.py
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
"""승인된 신규 페르소나의 세션 시작 계약 회귀 테스트."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from . import persona_repository, session_persistence
|
||||
from .deps import Principal, Role
|
||||
from .routes import sessions
|
||||
from .services import memory, persona as persona_service, state_machine
|
||||
from .store import store
|
||||
|
||||
|
||||
class _Transaction:
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _Acquire:
|
||||
def __init__(self, conn: "_ContractConnection") -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def __aenter__(self) -> "_ContractConnection":
|
||||
return self.conn
|
||||
|
||||
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class _ContractConnection:
|
||||
"""저장소·세션 SQL 계약을 보존하는 최소 상태형 DB 대역."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.personas: list[dict[str, Any]] = []
|
||||
self.session_insert_args: tuple[Any, ...] | None = None
|
||||
self.case_id = "00000000-0000-0000-0000-00000000ca5e"
|
||||
self.session_id = "00000000-0000-0000-0000-000000005355"
|
||||
|
||||
def transaction(self) -> _Transaction:
|
||||
return _Transaction()
|
||||
|
||||
async def fetchval(self, query: str, *args: Any) -> int:
|
||||
if "MAX(version)" not in query:
|
||||
raise AssertionError(f"unexpected fetchval: {query}")
|
||||
code = str(args[0]).upper()
|
||||
versions = [
|
||||
int(row["version"])
|
||||
for row in self.personas
|
||||
if str(row["code"]).upper() == code
|
||||
]
|
||||
return (max(versions) if versions else 0) + 1
|
||||
|
||||
async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
|
||||
if "FROM app.persona_card" not in query or "status = 'approved'" not in query:
|
||||
raise AssertionError(f"unexpected fetch: {query}")
|
||||
latest: dict[str, dict[str, Any]] = {}
|
||||
for row in self.personas:
|
||||
if row["status"] != "approved":
|
||||
continue
|
||||
code = str(row["code"]).upper()
|
||||
if code not in latest or int(row["version"]) > int(latest[code]["version"]):
|
||||
latest[code] = row
|
||||
return [latest[code] for code in sorted(latest)]
|
||||
|
||||
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
|
||||
now = datetime(2026, 8, 27, tzinfo=timezone.utc)
|
||||
if "INSERT INTO app.persona_card" in query:
|
||||
row = {
|
||||
"persona_id": str(args[0]),
|
||||
"code": str(args[1]).upper(),
|
||||
"version": int(args[2]),
|
||||
"status": str(args[3]),
|
||||
"display_name": args[4],
|
||||
"difficulty": args[5],
|
||||
"theory_target": list(args[6]),
|
||||
"demographics": dict(args[7]),
|
||||
"presenting": dict(args[8]),
|
||||
"history": dict(args[9]),
|
||||
"big5": dict(args[10]),
|
||||
"resistance": dict(args[11]),
|
||||
"speech_style": dict(args[12]),
|
||||
"affect_baseline": dict(args[13]),
|
||||
"ccd": dict(args[14]),
|
||||
"dsm5_dimensional": dict(args[15]),
|
||||
"source_provenance": args[16],
|
||||
"is_synthetic": bool(args[17]),
|
||||
"triggers": dict(args[18]),
|
||||
"created_by": args[19],
|
||||
"approved_by": None,
|
||||
"created_at": now,
|
||||
"approved_at": None,
|
||||
}
|
||||
self.personas.append(row)
|
||||
return row
|
||||
if "UPDATE app.persona_card" in query and "status IN ('draft', 'review')" in query:
|
||||
persona_id = str(args[0])
|
||||
for row in self.personas:
|
||||
if row["persona_id"] != persona_id or row["status"] not in {"draft", "review"}:
|
||||
continue
|
||||
row["status"] = str(args[1])
|
||||
row["approved_by"] = args[2]
|
||||
row["approved_at"] = now if row["status"] == "approved" else None
|
||||
return row
|
||||
return None
|
||||
if "FROM app.persona_card" in query and "status = 'approved'" in query:
|
||||
code = str(args[0]).upper()
|
||||
matches = [
|
||||
row
|
||||
for row in self.personas
|
||||
if row["status"] == "approved" and str(row["code"]).upper() == code
|
||||
]
|
||||
matches.sort(key=lambda row: int(row["version"]), reverse=True)
|
||||
return matches[0] if matches else None
|
||||
if "INSERT INTO app.case_profile" in query:
|
||||
return {"case_id": self.case_id, "last_session_no": 0}
|
||||
if "UPDATE app.case_profile" in query:
|
||||
return {"last_session_no": int(args[1])}
|
||||
if "INSERT INTO app.sessions" in query:
|
||||
self.session_insert_args = args
|
||||
return {
|
||||
"id": self.session_id,
|
||||
"runtime_case_id": args[0],
|
||||
"case_id": args[1],
|
||||
"learner_id": args[2],
|
||||
"persona_code": args[5],
|
||||
"session_no": args[8],
|
||||
"theory_mode": args[9],
|
||||
"started_at": now,
|
||||
"ended_at": None,
|
||||
"prev_rapport_credit": args[10],
|
||||
}
|
||||
raise AssertionError(f"unexpected fetchrow: {query}")
|
||||
|
||||
async def execute(self, query: str, *args: Any) -> str:
|
||||
if "audit.audit_log" in query or "app.session_state" in query:
|
||||
return "INSERT 0 1"
|
||||
raise AssertionError(f"unexpected execute: {query}")
|
||||
|
||||
|
||||
def _principal(role: Role, user_id: str) -> Principal:
|
||||
return Principal(
|
||||
user_id=user_id,
|
||||
role=role,
|
||||
cohort_ids=[],
|
||||
email=f"{role.value}@example.test",
|
||||
display_name=role.value.title(),
|
||||
consent_at=1.0 if role == Role.LEARNER else None,
|
||||
profile_completed_at=1.0 if role == Role.LEARNER else None,
|
||||
)
|
||||
|
||||
|
||||
class PersonaSessionContractTest(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()
|
||||
|
||||
def test_ideation_baseline_clamps_database_card_to_runtime_safety_contract(self) -> None:
|
||||
below_schema_minimum = replace(
|
||||
persona_service.P2,
|
||||
affect_baseline={
|
||||
**persona_service.P2.affect_baseline,
|
||||
"suicide_ideation_stage": 0,
|
||||
},
|
||||
)
|
||||
above_runtime_cap = replace(
|
||||
persona_service.P2,
|
||||
affect_baseline={
|
||||
**persona_service.P2.affect_baseline,
|
||||
"suicide_ideation_stage": 5,
|
||||
},
|
||||
)
|
||||
|
||||
self.assertEqual(below_schema_minimum.ideation_baseline(), 1)
|
||||
self.assertEqual(below_schema_minimum.openness_params().ideation_baseline, 1)
|
||||
self.assertEqual(above_runtime_cap.ideation_baseline(), 3)
|
||||
self.assertEqual(above_runtime_cap.openness_params().ideation_baseline, 3)
|
||||
|
||||
async def test_created_reviewed_and_approved_persona_starts_pinned_session(self) -> None:
|
||||
conn = _ContractConnection()
|
||||
teacher = _principal(Role.TEACHER, "00000000-0000-0000-0000-000000000901")
|
||||
learner = _principal(Role.LEARNER, "00000000-0000-0000-0000-000000000902")
|
||||
card = replace(
|
||||
persona_service.P1,
|
||||
code="P8",
|
||||
display_name="신규 내담자(가명)",
|
||||
presenting={
|
||||
"complaint": "새로 등록한 주호소",
|
||||
"surface": "신뢰가 쌓인 뒤 구체화됨",
|
||||
},
|
||||
source_provenance="contract test",
|
||||
)
|
||||
|
||||
def acquire(**_: Any) -> _Acquire:
|
||||
return _Acquire(conn)
|
||||
|
||||
def close_background(coro: Any) -> SimpleNamespace:
|
||||
coro.close()
|
||||
return SimpleNamespace()
|
||||
|
||||
with (
|
||||
patch.object(persona_repository, "get_pool", return_value=object()),
|
||||
patch.object(persona_repository, "acquire", acquire),
|
||||
patch.object(session_persistence, "get_pool", return_value=object()),
|
||||
patch.object(session_persistence, "acquire", acquire),
|
||||
patch.object(
|
||||
sessions,
|
||||
"_build_seed_recall",
|
||||
AsyncMock(return_value=memory.RecallContext()),
|
||||
),
|
||||
patch.object(sessions.asyncio, "create_task", close_background),
|
||||
):
|
||||
created = await persona_repository.create_persona_draft(
|
||||
card=card,
|
||||
author_id=teacher.user_id,
|
||||
role="teacher",
|
||||
submit_for_review=True,
|
||||
)
|
||||
approved = await persona_repository.update_persona_review_status(
|
||||
persona_id=created.persona_id,
|
||||
action="approve",
|
||||
reviewer_id=teacher.user_id,
|
||||
role="teacher",
|
||||
)
|
||||
catalog = await persona_repository.list_catalog_personas()
|
||||
response = await sessions.start_session(
|
||||
sessions.SessionStartRequest(persona_code="P8"),
|
||||
learner,
|
||||
)
|
||||
|
||||
self.assertEqual(created.status, "review")
|
||||
self.assertIsNotNone(approved)
|
||||
assert approved is not None
|
||||
self.assertEqual(approved.status, "approved")
|
||||
self.assertEqual([entry.card.code for entry in catalog], ["P8"])
|
||||
self.assertEqual(response.session_id, conn.session_id)
|
||||
self.assertEqual(response.persona_id, created.persona_id)
|
||||
self.assertEqual(response.persona_version, created.version)
|
||||
self.assertFalse(response.degraded)
|
||||
self.assertIsNotNone(conn.session_insert_args)
|
||||
assert conn.session_insert_args is not None
|
||||
self.assertEqual(str(conn.session_insert_args[3]), created.persona_id)
|
||||
self.assertEqual(conn.session_insert_args[4], created.version)
|
||||
self.assertEqual(conn.session_insert_args[5], "P8")
|
||||
|
||||
async def test_create_session_raises_typed_error_when_durable_write_fails(self) -> None:
|
||||
with (
|
||||
patch.object(session_persistence, "get_pool", side_effect=RuntimeError("db down")),
|
||||
patch.object(session_persistence, "runtime_fallback_allowed", return_value=False),
|
||||
):
|
||||
with self.assertRaises(session_persistence.SessionCreationPersistenceError) as caught:
|
||||
await session_persistence.create_session(
|
||||
learner_id="00000000-0000-0000-0000-000000000902",
|
||||
card=persona_service.P1,
|
||||
theory_mode="humanistic",
|
||||
state=state_machine.SessionState(),
|
||||
)
|
||||
|
||||
self.assertEqual(str(caught.exception), "session_persistence_unavailable")
|
||||
self.assertIsInstance(caught.exception.__cause__, RuntimeError)
|
||||
|
||||
async def test_start_route_exposes_stable_persistence_failure(self) -> None:
|
||||
learner = _principal(Role.LEARNER, "00000000-0000-0000-0000-000000000902")
|
||||
catalog_persona = persona_repository.CatalogPersona(
|
||||
card=replace(persona_service.P1, code="P8"),
|
||||
persona_id="00000000-0000-0000-0000-000000000908",
|
||||
version=1,
|
||||
source="database",
|
||||
)
|
||||
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",
|
||||
AsyncMock(
|
||||
side_effect=session_persistence.SessionCreationPersistenceError(
|
||||
"session_persistence_unavailable"
|
||||
)
|
||||
),
|
||||
),
|
||||
):
|
||||
with self.assertRaises(sessions.HTTPException) as caught:
|
||||
await sessions.start_session(
|
||||
sessions.SessionStartRequest(persona_code="P8"),
|
||||
learner,
|
||||
)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 503)
|
||||
self.assertEqual(caught.exception.detail, "session_persistence_unavailable")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -115,6 +115,23 @@ class PiiMaskingEvalTests(unittest.TestCase):
|
|||
result = guardrail.mask_pii(raw)
|
||||
self.assertEqual(result.text_masked, expected)
|
||||
|
||||
def test_known_session_identities_use_role_tokens_without_partial_name_collision(self) -> None:
|
||||
result = guardrail.mask_role_identities(
|
||||
"저는 윤찬 상담자이고 서연 씨와 이야기합니다. 김서연은 보호자입니다.",
|
||||
counselor_identity="윤찬",
|
||||
client_identity="서연(가명) · 고2 · 우울/자살사고",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
result.text_masked,
|
||||
"저는 [COUNSELOR] 상담자이고 [CLIENT] 씨와 이야기합니다. [NAME]은 보호자입니다.",
|
||||
)
|
||||
self.assertNotIn("윤찬", result.text_masked)
|
||||
self.assertNotIn("서연 씨", result.text_masked)
|
||||
self.assertIn("ROLE_COUNSELOR", result.entities)
|
||||
self.assertIn("ROLE_CLIENT", result.entities)
|
||||
self.assertIn("NAME", result.entities)
|
||||
|
||||
def test_optional_ko_recognizer_masks_adapter_spans_before_regex_fallback(self) -> None:
|
||||
class FakeKoRecognizer:
|
||||
def analyze(self, text: str):
|
||||
|
|
|
|||
544
apps/api/app/test_protocol_registry.py
Normal file
544
apps/api/app/test_protocol_registry.py
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import asynccontextmanager
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
import unittest
|
||||
from unittest.mock import AsyncMock, patch
|
||||
from uuid import UUID
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.testclient import TestClient
|
||||
from pydantic import ValidationError
|
||||
|
||||
from . import db
|
||||
from .deps import Principal, Role, get_current_principal
|
||||
from .main import app as main_app
|
||||
from .routes import protocols
|
||||
from .services import live_coach, protocol_registry, rag
|
||||
|
||||
|
||||
NOW = datetime(2026, 8, 27, 1, 0, tzinfo=timezone.utc)
|
||||
PROTOCOL_ID = "71000000-0000-4000-8000-000000000001"
|
||||
ADMIN_ID = "71000000-0000-4000-8000-000000000099"
|
||||
|
||||
|
||||
def protocol_row(status: str = "draft", **overrides: object) -> dict[str, object]:
|
||||
activated_at = NOW if status in {"active", "retired"} else None
|
||||
retired_at = NOW if status == "retired" else None
|
||||
row: dict[str, object] = {
|
||||
"protocol_id": PROTOCOL_ID,
|
||||
"source_id": f"protocol:{PROTOCOL_ID}",
|
||||
"title": "위기 개입 기본 프로토콜",
|
||||
"source_ref": "https://example.edu/protocols/crisis-v1",
|
||||
"version": 1,
|
||||
"license_class": "B",
|
||||
"external_llm_ok": False,
|
||||
"content": "위험도를 먼저 확인한다.\n\n안전 계획을 함께 수립한다.",
|
||||
"content_hash": "a" * 64,
|
||||
"status": status,
|
||||
"registered_by": ADMIN_ID,
|
||||
"registered_at": NOW,
|
||||
"activated_at": activated_at,
|
||||
"retired_at": retired_at,
|
||||
}
|
||||
row.update(overrides)
|
||||
return row
|
||||
|
||||
|
||||
class FakeProtocolConnection:
|
||||
def __init__(self, row: dict[str, object] | None = None) -> None:
|
||||
self.row = row
|
||||
self.rows: list[dict[str, object]] = [row] if row else []
|
||||
self.fetchrow_calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
self.fetch_calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
self.execute_calls: list[tuple[str, tuple[object, ...]]] = []
|
||||
self.source_registered = False
|
||||
self.documents_active = row is not None and row.get("status") == "active"
|
||||
self.fail_on_retire_status = False
|
||||
self.transaction_entries = 0
|
||||
self.transaction_commits = 0
|
||||
self.transaction_rollbacks = 0
|
||||
self.transaction_lock = asyncio.Lock()
|
||||
|
||||
class _Transaction:
|
||||
def __init__(self, conn: "FakeProtocolConnection") -> None:
|
||||
self.conn = conn
|
||||
self.snapshot: tuple[
|
||||
dict[str, object] | None,
|
||||
list[dict[str, object]],
|
||||
bool,
|
||||
bool,
|
||||
] | None = None
|
||||
|
||||
async def __aenter__(self) -> "FakeProtocolConnection._Transaction":
|
||||
await self.conn.transaction_lock.acquire()
|
||||
self.conn.transaction_entries += 1
|
||||
self.snapshot = (
|
||||
deepcopy(self.conn.row),
|
||||
deepcopy(self.conn.rows),
|
||||
self.conn.source_registered,
|
||||
self.conn.documents_active,
|
||||
)
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, traceback) -> bool:
|
||||
try:
|
||||
if exc_type is None:
|
||||
self.conn.transaction_commits += 1
|
||||
else:
|
||||
assert self.snapshot is not None
|
||||
(
|
||||
self.conn.row,
|
||||
self.conn.rows,
|
||||
self.conn.source_registered,
|
||||
self.conn.documents_active,
|
||||
) = self.snapshot
|
||||
self.conn.transaction_rollbacks += 1
|
||||
finally:
|
||||
self.conn.transaction_lock.release()
|
||||
return False
|
||||
|
||||
def transaction(self) -> "FakeProtocolConnection._Transaction":
|
||||
return self._Transaction(self)
|
||||
|
||||
async def fetchrow(self, query: str, *args: object) -> dict[str, object] | None:
|
||||
self.fetchrow_calls.append((query, args))
|
||||
if "INSERT INTO kb.protocol_registration" in query:
|
||||
self.row = protocol_row(
|
||||
protocol_id=args[0],
|
||||
source_id=args[1],
|
||||
title=args[2],
|
||||
source_ref=args[3],
|
||||
version=args[4],
|
||||
license_class=args[5],
|
||||
external_llm_ok=args[6],
|
||||
content=args[7],
|
||||
content_hash=args[8],
|
||||
registered_by=args[9],
|
||||
)
|
||||
return self.row
|
||||
if "SET status = 'active'" in query and self.row is not None:
|
||||
self.row = {**self.row, "status": "active", "activated_at": NOW}
|
||||
return self.row
|
||||
if "SET status = 'retired'" in query and self.row is not None:
|
||||
if self.fail_on_retire_status:
|
||||
raise RuntimeError("retire status write failed")
|
||||
self.row = {**self.row, "status": "retired", "retired_at": NOW}
|
||||
return self.row
|
||||
return self.row
|
||||
|
||||
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
|
||||
self.fetch_calls.append((query, args))
|
||||
return self.rows
|
||||
|
||||
async def execute(self, query: str, *args: object) -> str:
|
||||
self.execute_calls.append((query, args))
|
||||
if "INSERT INTO kb.source" in query:
|
||||
self.source_registered = True
|
||||
if "UPDATE kb.document SET is_active = FALSE" in query:
|
||||
self.documents_active = False
|
||||
return "UPDATE 1"
|
||||
|
||||
|
||||
class ProtocolRegistryUnitTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_create_stores_normalized_draft_and_server_hash_only(self) -> None:
|
||||
conn = FakeProtocolConnection()
|
||||
|
||||
record = await protocol_registry.create_protocol(
|
||||
conn,
|
||||
title=" 위기 개입 기본 프로토콜 ",
|
||||
source=" https://example.edu/protocols/crisis-v1 ",
|
||||
version=3,
|
||||
license_class="B",
|
||||
external_llm_ok=False,
|
||||
content="첫 문단\r\n\r\n둘째 문단\r\n",
|
||||
registered_by=ADMIN_ID,
|
||||
)
|
||||
|
||||
self.assertEqual(record.status, "draft")
|
||||
self.assertEqual(record.title, "위기 개입 기본 프로토콜")
|
||||
self.assertEqual(record.content, "첫 문단\n\n둘째 문단")
|
||||
self.assertEqual(record.content_hash, protocol_registry.content_hash(record.content))
|
||||
self.assertTrue(record.source_id.startswith("protocol:"))
|
||||
self.assertEqual(conn.execute_calls, [], "draft creation must not touch kb.source/chunks")
|
||||
|
||||
async def test_activate_reuses_source_document_chunk_and_preserves_license(self) -> None:
|
||||
conn = FakeProtocolConnection(protocol_row())
|
||||
indexed = rag.IndexResult(
|
||||
doc_id=31,
|
||||
chunks_indexed=2,
|
||||
skipped_unchanged=False,
|
||||
embedded=True,
|
||||
degraded=False,
|
||||
)
|
||||
fake_index = AsyncMock(return_value=indexed)
|
||||
|
||||
with patch.object(protocol_registry.rag, "index_document", fake_index):
|
||||
record, result = await protocol_registry.activate_protocol(
|
||||
conn,
|
||||
protocol_id=PROTOCOL_ID,
|
||||
)
|
||||
|
||||
self.assertEqual(record.status, "active")
|
||||
self.assertEqual(result, indexed)
|
||||
source_call = next(call for call in conn.execute_calls if "INSERT INTO kb.source" in call[0])
|
||||
self.assertEqual(source_call[1][2], "B")
|
||||
self.assertIs(source_call[1][4], False)
|
||||
index_request = fake_index.await_args.args[1]
|
||||
self.assertEqual(index_request.source_id, f"protocol:{PROTOCOL_ID}")
|
||||
self.assertEqual(index_request.content_hash, "a" * 64)
|
||||
self.assertTrue(index_request.chunks)
|
||||
self.assertEqual(index_request.chunks[0]["meta"]["license_class"], "B")
|
||||
self.assertIs(index_request.chunks[0]["meta"]["external_llm_ok"], False)
|
||||
|
||||
async def test_activation_failure_never_marks_draft_active(self) -> None:
|
||||
conn = FakeProtocolConnection(protocol_row())
|
||||
fake_index = AsyncMock(side_effect=rag.NotConfigured("vector unavailable"))
|
||||
|
||||
with (
|
||||
patch.object(protocol_registry.rag, "index_document", fake_index),
|
||||
self.assertRaises(protocol_registry.ProtocolStoreUnavailable),
|
||||
):
|
||||
await protocol_registry.activate_protocol(conn, protocol_id=PROTOCOL_ID)
|
||||
|
||||
self.assertEqual(conn.row["status"], "draft") # type: ignore[index]
|
||||
self.assertFalse(
|
||||
any("SET status = 'active'" in query for query, _ in conn.fetchrow_calls)
|
||||
)
|
||||
|
||||
async def test_retire_deactivates_documents_before_terminal_status(self) -> None:
|
||||
conn = FakeProtocolConnection(protocol_row("active"))
|
||||
|
||||
record = await protocol_registry.retire_protocol(conn, protocol_id=PROTOCOL_ID)
|
||||
|
||||
self.assertEqual(record.status, "retired")
|
||||
self.assertIn("UPDATE kb.document SET is_active = FALSE", conn.execute_calls[0][0])
|
||||
self.assertEqual(conn.execute_calls[0][1], (f"protocol:{PROTOCOL_ID}",))
|
||||
|
||||
async def test_retired_protocol_cannot_be_activated_again(self) -> None:
|
||||
conn = FakeProtocolConnection(protocol_row("retired"))
|
||||
with self.assertRaises(protocol_registry.ProtocolTransitionConflict):
|
||||
await protocol_registry.activate_protocol(conn, protocol_id=PROTOCOL_ID)
|
||||
|
||||
async def test_license_c_or_d_cannot_enable_external_llm(self) -> None:
|
||||
for license_class in ("C", "D"):
|
||||
with self.subTest(license_class=license_class):
|
||||
with self.assertRaises(protocol_registry.ProtocolPolicyViolation):
|
||||
protocol_registry.validate_license_policy(license_class, True)
|
||||
|
||||
|
||||
class ProtocolReadinessTests(unittest.IsolatedAsyncioTestCase):
|
||||
@staticmethod
|
||||
def _acquire(conn: FakeProtocolConnection):
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs: object):
|
||||
yield conn
|
||||
|
||||
return fake_acquire
|
||||
|
||||
async def test_complete_owner_migration_requires_no_runtime_ddl(self) -> None:
|
||||
conn = FakeProtocolConnection(
|
||||
{
|
||||
"protocol_table": True,
|
||||
"source_license_constraint": True,
|
||||
"protocol_license_constraint": True,
|
||||
"protocol_lifecycle_constraint": True,
|
||||
"protocol_chunk_write_policy": True,
|
||||
"protocol_status_index": True,
|
||||
}
|
||||
)
|
||||
|
||||
with patch.object(db, "acquire", self._acquire(conn)):
|
||||
await protocol_registry.ensure_protocol_tables()
|
||||
|
||||
self.assertEqual(conn.execute_calls, [])
|
||||
self.assertEqual(len(conn.fetchrow_calls), 1)
|
||||
readiness_sql = conn.fetchrow_calls[0][0]
|
||||
self.assertIn("to_regclass('kb.protocol_registration')", readiness_sql)
|
||||
self.assertNotIn("CREATE SCHEMA", readiness_sql)
|
||||
self.assertNotIn("CREATE TABLE", readiness_sql)
|
||||
|
||||
async def test_missing_contract_fails_closed_without_runtime_ddl(self) -> None:
|
||||
conn = FakeProtocolConnection(
|
||||
{
|
||||
"protocol_table": True,
|
||||
"source_license_constraint": True,
|
||||
"protocol_license_constraint": False,
|
||||
"protocol_lifecycle_constraint": True,
|
||||
"protocol_chunk_write_policy": False,
|
||||
"protocol_status_index": False,
|
||||
}
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(db, "acquire", self._acquire(conn)),
|
||||
self.assertRaises(protocol_registry.ProtocolStoreUnavailable) as raised,
|
||||
):
|
||||
await protocol_registry.ensure_protocol_tables()
|
||||
|
||||
self.assertIn("17_improvement_workbook_contracts.sql", str(raised.exception))
|
||||
self.assertIn("protocol_license_constraint", str(raised.exception))
|
||||
self.assertIn("protocol_chunk_write_policy", str(raised.exception))
|
||||
self.assertIn("protocol_status_index", str(raised.exception))
|
||||
self.assertEqual(conn.execute_calls, [])
|
||||
|
||||
|
||||
class ProtocolLifecycleTransactionTests(unittest.IsolatedAsyncioTestCase):
|
||||
def setUp(self) -> None:
|
||||
self.principal = Principal(user_id=ADMIN_ID, role=Role.ADMIN)
|
||||
|
||||
@staticmethod
|
||||
def _acquire(conn: FakeProtocolConnection):
|
||||
@asynccontextmanager
|
||||
async def fake_acquire(**_kwargs: object):
|
||||
yield conn
|
||||
|
||||
return fake_acquire
|
||||
|
||||
async def test_activate_rolls_back_source_when_indexing_fails(self) -> None:
|
||||
conn = FakeProtocolConnection(protocol_row())
|
||||
fake_index = AsyncMock(side_effect=rag.NotConfigured("vector unavailable"))
|
||||
|
||||
with (
|
||||
patch.object(protocols, "acquire", self._acquire(conn)),
|
||||
patch.object(protocol_registry.rag, "index_document", fake_index),
|
||||
self.assertRaises(HTTPException) as raised,
|
||||
):
|
||||
await protocols.activate_admin_protocol(
|
||||
UUID(PROTOCOL_ID),
|
||||
self.principal,
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 503)
|
||||
self.assertEqual(conn.transaction_entries, 1)
|
||||
self.assertEqual(conn.transaction_commits, 0)
|
||||
self.assertEqual(conn.transaction_rollbacks, 1)
|
||||
self.assertFalse(conn.source_registered)
|
||||
self.assertEqual(conn.row["status"], "draft") # type: ignore[index]
|
||||
|
||||
async def test_retire_rolls_back_document_deactivation_when_status_write_fails(
|
||||
self,
|
||||
) -> None:
|
||||
conn = FakeProtocolConnection(protocol_row("active"))
|
||||
conn.fail_on_retire_status = True
|
||||
|
||||
with (
|
||||
patch.object(protocols, "acquire", self._acquire(conn)),
|
||||
self.assertRaises(HTTPException) as raised,
|
||||
):
|
||||
await protocols.retire_admin_protocol(
|
||||
UUID(PROTOCOL_ID),
|
||||
self.principal,
|
||||
)
|
||||
|
||||
self.assertEqual(raised.exception.status_code, 503)
|
||||
self.assertEqual(conn.transaction_entries, 1)
|
||||
self.assertEqual(conn.transaction_commits, 0)
|
||||
self.assertEqual(conn.transaction_rollbacks, 1)
|
||||
self.assertTrue(conn.documents_active)
|
||||
self.assertEqual(conn.row["status"], "active") # type: ignore[index]
|
||||
|
||||
async def test_concurrent_activation_serializes_and_indexes_once(self) -> None:
|
||||
conn = FakeProtocolConnection(protocol_row())
|
||||
indexed = rag.IndexResult(
|
||||
doc_id=31,
|
||||
chunks_indexed=2,
|
||||
skipped_unchanged=False,
|
||||
embedded=True,
|
||||
degraded=False,
|
||||
)
|
||||
fake_index = AsyncMock(return_value=indexed)
|
||||
|
||||
with (
|
||||
patch.object(protocols, "acquire", self._acquire(conn)),
|
||||
patch.object(protocol_registry.rag, "index_document", fake_index),
|
||||
):
|
||||
results = await asyncio.gather(
|
||||
protocols.activate_admin_protocol(UUID(PROTOCOL_ID), self.principal),
|
||||
protocols.activate_admin_protocol(UUID(PROTOCOL_ID), self.principal),
|
||||
return_exceptions=True,
|
||||
)
|
||||
|
||||
successes = [item for item in results if not isinstance(item, Exception)]
|
||||
conflicts = [
|
||||
item
|
||||
for item in results
|
||||
if isinstance(item, HTTPException) and item.status_code == 409
|
||||
]
|
||||
self.assertEqual(len(successes), 1)
|
||||
self.assertEqual(len(conflicts), 1)
|
||||
self.assertEqual(fake_index.await_count, 1)
|
||||
self.assertEqual(conn.transaction_entries, 2)
|
||||
self.assertEqual(conn.transaction_commits, 1)
|
||||
self.assertEqual(conn.transaction_rollbacks, 1)
|
||||
self.assertEqual(conn.row["status"], "active") # type: ignore[index]
|
||||
|
||||
|
||||
class IndexSourcePolicyTests(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_unregistered_source_is_rejected(self) -> None:
|
||||
conn = FakeProtocolConnection(None)
|
||||
with self.assertRaises(rag.IndexPolicyViolation):
|
||||
await rag.validate_index_source(conn, "unknown") # type: ignore[arg-type]
|
||||
|
||||
async def test_copyright_sensitive_external_source_is_rejected(self) -> None:
|
||||
conn = FakeProtocolConnection(
|
||||
{"license_class": "C", "external_llm_ok": True, "protocol_status": None}
|
||||
)
|
||||
with self.assertRaises(rag.IndexPolicyViolation):
|
||||
await rag.validate_index_source(conn, "unsafe") # type: ignore[arg-type]
|
||||
|
||||
async def test_draft_protocol_source_is_rejected(self) -> None:
|
||||
conn = FakeProtocolConnection(
|
||||
{"license_class": "B", "external_llm_ok": False, "protocol_status": "draft"}
|
||||
)
|
||||
with self.assertRaises(rag.IndexPolicyViolation):
|
||||
await rag.validate_index_source(conn, "protocol:draft") # type: ignore[arg-type]
|
||||
|
||||
async def test_active_or_legacy_registered_source_is_allowed(self) -> None:
|
||||
for protocol_status in ("active", None):
|
||||
with self.subTest(protocol_status=protocol_status):
|
||||
conn = FakeProtocolConnection(
|
||||
{
|
||||
"license_class": "A",
|
||||
"external_llm_ok": True,
|
||||
"protocol_status": protocol_status,
|
||||
}
|
||||
)
|
||||
await rag.validate_index_source(conn, "registered") # type: ignore[arg-type]
|
||||
|
||||
|
||||
class ProtocolContractTests(unittest.TestCase):
|
||||
def test_schema_and_search_are_fail_closed(self) -> None:
|
||||
self.assertIn("status TEXT NOT NULL DEFAULT 'draft'", protocol_registry.PROTOCOL_SCHEMA_SQL)
|
||||
self.assertIn("ck_protocol_external_license", protocol_registry.PROTOCOL_SCHEMA_SQL)
|
||||
self.assertIn("p_kb_chunk_admin_write", protocol_registry.PROTOCOL_SCHEMA_SQL)
|
||||
self.assertIn("app.current_role_name() = 'admin'", protocol_registry.PROTOCOL_SCHEMA_SQL)
|
||||
self.assertIn("p_kb_chunk_admin_write", protocol_registry.PROTOCOL_READINESS_SQL)
|
||||
self.assertEqual(rag._HYBRID_SQL.count("JOIN kb.document d"), 2)
|
||||
self.assertEqual(rag._HYBRID_SQL.count("LEFT JOIN kb.protocol_registration pr"), 2)
|
||||
self.assertEqual(rag._HYBRID_SQL.count("pr.status = 'active'"), 2)
|
||||
self.assertIn("JOIN kb.source src ON src.source_id = c.source_id", rag._HYBRID_SQL)
|
||||
self.assertIn("src.license_class, src.external_llm_ok", rag._HYBRID_SQL)
|
||||
|
||||
def test_registered_chunks_are_evaluator_only_and_sensitivity_two(self) -> None:
|
||||
record = protocol_registry._record(protocol_row("active"))
|
||||
|
||||
chunks = protocol_registry.build_index_chunks(record, limit=32)
|
||||
|
||||
self.assertGreater(len(chunks), 0)
|
||||
self.assertTrue(all(chunk["visible_to"] == ["evaluator"] for chunk in chunks))
|
||||
self.assertTrue(all(chunk["sensitivity"] == 2 for chunk in chunks))
|
||||
self.assertTrue(
|
||||
all(chunk["meta"]["license_class"] == "B" for chunk in chunks)
|
||||
)
|
||||
self.assertTrue(
|
||||
all(chunk["meta"]["external_llm_ok"] is False for chunk in chunks)
|
||||
)
|
||||
|
||||
def test_external_prompt_grounding_allows_only_explicit_a_or_b(self) -> None:
|
||||
grounding = [
|
||||
live_coach.LiveCoachGrounding(
|
||||
source_id="allowed-a",
|
||||
title="A",
|
||||
license_class="A",
|
||||
external_llm_ok=True,
|
||||
summary="allowed",
|
||||
),
|
||||
live_coach.LiveCoachGrounding(
|
||||
source_id="allowed-b",
|
||||
title="B",
|
||||
license_class="B",
|
||||
external_llm_ok=True,
|
||||
summary="allowed",
|
||||
),
|
||||
live_coach.LiveCoachGrounding(
|
||||
source_id="denied-flag",
|
||||
title="A false",
|
||||
license_class="A",
|
||||
external_llm_ok=False,
|
||||
summary="denied",
|
||||
),
|
||||
live_coach.LiveCoachGrounding(
|
||||
source_id="denied-c",
|
||||
title="C",
|
||||
license_class="C",
|
||||
external_llm_ok=True,
|
||||
summary="denied",
|
||||
),
|
||||
live_coach.LiveCoachGrounding(
|
||||
source_id="denied-missing",
|
||||
title="missing",
|
||||
summary="denied",
|
||||
),
|
||||
]
|
||||
|
||||
safe = live_coach._external_safe_grounding(grounding)
|
||||
|
||||
self.assertEqual([item.source_id for item in safe], ["allowed-a", "allowed-b"])
|
||||
|
||||
def test_create_contract_rejects_license_bypass(self) -> None:
|
||||
with self.assertRaises(ValidationError):
|
||||
protocols.AdminProtocolCreate(
|
||||
title="민감 프로토콜",
|
||||
source="licensed://restricted",
|
||||
version=1,
|
||||
license="C",
|
||||
external_llm_ok=True,
|
||||
content="저작권 민감 원문",
|
||||
)
|
||||
|
||||
def test_owner_migration_closes_existing_db_contracts(self) -> None:
|
||||
migration = (
|
||||
Path(__file__).resolve().parents[3]
|
||||
/ "infra"
|
||||
/ "db"
|
||||
/ "init"
|
||||
/ "17_improvement_workbook_contracts.sql"
|
||||
).read_text(encoding="utf-8")
|
||||
|
||||
self.assertIn(
|
||||
"ADD COLUMN IF NOT EXISTS learner_feedback_enabled BOOLEAN NOT NULL DEFAULT TRUE",
|
||||
migration,
|
||||
)
|
||||
self.assertEqual(migration.count("learner_feedback_enabled"), 2)
|
||||
self.assertIn("CREATE TABLE IF NOT EXISTS kb.protocol_registration", migration)
|
||||
self.assertIn("CREATE INDEX IF NOT EXISTS idx_protocol_registration_status", migration)
|
||||
self.assertLess(
|
||||
migration.index("UPDATE kb.source"),
|
||||
migration.index("VALIDATE CONSTRAINT ck_kb_source_external_license"),
|
||||
)
|
||||
self.assertLess(
|
||||
migration.index("UPDATE kb.protocol_registration"),
|
||||
migration.index("VALIDATE CONSTRAINT ck_protocol_external_license"),
|
||||
)
|
||||
|
||||
def test_openapi_exposes_typed_admin_lifecycle(self) -> None:
|
||||
schema = main_app.openapi()
|
||||
paths = schema["paths"]
|
||||
self.assertIn("/admin/protocols", paths)
|
||||
self.assertIn("/admin/protocols/{protocol_id}/activate", paths)
|
||||
self.assertIn("/admin/protocols/{protocol_id}/retire", paths)
|
||||
create_schema = schema["components"]["schemas"]["AdminProtocolCreate"]
|
||||
self.assertIn("license", create_schema["required"])
|
||||
self.assertIn("content", create_schema["required"])
|
||||
response_schema = schema["components"]["schemas"]["AdminProtocolResponse"]
|
||||
self.assertIn("content_hash", response_schema["required"])
|
||||
self.assertIn("registered_at", response_schema["required"])
|
||||
|
||||
def test_learner_is_forbidden_before_protocol_store_access(self) -> None:
|
||||
contract_app = FastAPI()
|
||||
contract_app.include_router(protocols.router)
|
||||
contract_app.dependency_overrides[get_current_principal] = lambda: Principal(
|
||||
user_id="learner-1",
|
||||
role=Role.LEARNER,
|
||||
)
|
||||
with TestClient(contract_app) as client:
|
||||
response = client.get("/admin/protocols")
|
||||
self.assertEqual(response.status_code, 403)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -650,6 +650,7 @@ def _route_session(
|
|||
state=replace(state, turn_seq=eligible.turn_seq - 1),
|
||||
theory_mode="humanistic",
|
||||
created_at=time.time(),
|
||||
learner_label="테스트 학습자",
|
||||
recent_turns=lambda *, visible_to: [],
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,10 +3,13 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from starlette.requests import Request
|
||||
|
||||
from . import session_persistence
|
||||
from . import auth_sessions
|
||||
from .deps import Principal, Role
|
||||
from .routes import sessions
|
||||
from .routes import share as share_routes
|
||||
|
|
@ -156,3 +159,25 @@ class SessionShareTest(unittest.IsolatedAsyncioTestCase):
|
|||
with self.assertRaises(share_routes.HTTPException) as caught:
|
||||
await share_routes._load_share_or_404(token)
|
||||
self.assertEqual(caught.exception.status_code, 404)
|
||||
|
||||
async def test_current_feedback_off_blocks_an_existing_public_share(self) -> None:
|
||||
principal = _principal()
|
||||
_ended_session(principal)
|
||||
response = await sessions.create_session_share(
|
||||
"share-test-session",
|
||||
_request(),
|
||||
principal,
|
||||
)
|
||||
token = response.shareUrl.rsplit("/", 1)[-1]
|
||||
|
||||
with patch.object(
|
||||
auth_sessions,
|
||||
"get_managed_user",
|
||||
AsyncMock(
|
||||
return_value=SimpleNamespace(learner_feedback_enabled=False)
|
||||
),
|
||||
):
|
||||
with self.assertRaises(share_routes.HTTPException) as caught:
|
||||
await share_routes._load_share_or_404(token)
|
||||
|
||||
self.assertEqual(caught.exception.status_code, 404)
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ from . import session_persistence, turn_runtime
|
|||
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
|
||||
from .deps import Principal, Role
|
||||
from .engine_client import EngineError, GenerateResponse
|
||||
from .routes import eval as eval_routes
|
||||
from .routes import sessions
|
||||
from .routes import voice as voice_routes
|
||||
from .services import (
|
||||
|
|
@ -129,6 +130,293 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(response.session_id, sess.session_id)
|
||||
schedule_session_evaluation.assert_not_called()
|
||||
|
||||
async def test_failed_end_evaluation_keeps_old_review_and_next_session_writable(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
card = persona_service.P1
|
||||
case_id = "00000000-0000-0000-0000-00000000ca5e"
|
||||
old_session = _session(principal)
|
||||
old_session.case_id = case_id
|
||||
old_session.session_no = 1
|
||||
old_session.turns.extend(
|
||||
[
|
||||
TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="learner",
|
||||
stage=old_session.state.stage.value,
|
||||
text="지금 가장 버거운 마음이 어떤 건가요?",
|
||||
text_masked="지금 가장 버거운 마음이 어떤 건가요?",
|
||||
),
|
||||
TurnRecord(
|
||||
turn_seq=2,
|
||||
speaker="client",
|
||||
stage=old_session.state.stage.value,
|
||||
text="아무것도 하고 싶지 않아요.",
|
||||
text_masked="아무것도 하고 싶지 않아요.",
|
||||
),
|
||||
]
|
||||
)
|
||||
durable_sessions = {old_session.session_id: old_session}
|
||||
end_order: list[str] = []
|
||||
|
||||
async def persist_end(sess: InProcSession, _carry: memory.CarryOver) -> None:
|
||||
end_order.append("persisted")
|
||||
sess.ended = True
|
||||
sess.ended_at = sess.created_at + 120
|
||||
durable_sessions[sess.session_id] = sess
|
||||
|
||||
def schedule_evaluation(sess: InProcSession) -> None:
|
||||
self.assertTrue(durable_sessions[sess.session_id].ended)
|
||||
end_order.append("evaluation_scheduled")
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=old_session),
|
||||
),
|
||||
patch.object(sessions, "_end_persisted_session", persist_end),
|
||||
patch.object(
|
||||
sessions.rupture_runtime,
|
||||
"schedule_session_scan",
|
||||
),
|
||||
patch.object(
|
||||
sessions,
|
||||
"_schedule_session_evaluation",
|
||||
schedule_evaluation,
|
||||
),
|
||||
):
|
||||
ended = await sessions.end_session(old_session.session_id, principal)
|
||||
|
||||
self.assertEqual(ended.session_id, old_session.session_id)
|
||||
self.assertEqual(ended.session_no, 1)
|
||||
self.assertEqual(end_order, ["persisted", "evaluation_scheduled"])
|
||||
self.assertTrue(durable_sessions[old_session.session_id].ended)
|
||||
self.assertIsNotNone(durable_sessions[old_session.session_id].ended_at)
|
||||
|
||||
durable_evaluations: dict[str, dict[str, object]] = {}
|
||||
|
||||
async def save_evaluation(
|
||||
write: session_persistence.SessionEvaluationWrite,
|
||||
) -> bool:
|
||||
durable_evaluations[write.session_id] = write.cache_record()
|
||||
return True
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions.evaluator,
|
||||
"evaluate_session",
|
||||
AsyncMock(side_effect=asyncio.TimeoutError),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"save_session_evaluation",
|
||||
save_evaluation,
|
||||
),
|
||||
patch.object(
|
||||
sessions,
|
||||
"_enqueue_session_review_ready_notification",
|
||||
AsyncMock(return_value=None),
|
||||
),
|
||||
):
|
||||
await sessions._generate_and_save_session_evaluation(old_session)
|
||||
|
||||
old_evaluation = durable_evaluations[old_session.session_id]
|
||||
self.assertEqual(old_evaluation["status"], "error")
|
||||
self.assertIn("session evaluation timeout", str(old_evaluation["error"]))
|
||||
self.assertTrue(durable_sessions[old_session.session_id].ended)
|
||||
|
||||
catalog_persona = SimpleNamespace(
|
||||
card=card,
|
||||
persona_id="00000000-0000-0000-0000-0000000000a1",
|
||||
version=3,
|
||||
degraded=False,
|
||||
)
|
||||
case_context = sessions.session_persistence.CaseContext(
|
||||
case_id=case_id,
|
||||
last_session_no=1,
|
||||
)
|
||||
next_session_id = "00000000-0000-0000-0000-000000000702"
|
||||
|
||||
async def create_next_session(**kwargs: object) -> InProcSession:
|
||||
self.assertEqual(kwargs["case_id"], case_id)
|
||||
self.assertEqual(kwargs["session_no"], 2)
|
||||
created = InProcSession(
|
||||
session_id=next_session_id,
|
||||
case_id=case_id,
|
||||
learner_id=principal.user_id,
|
||||
persona_code=card.code,
|
||||
theory_mode=str(kwargs["theory_mode"]),
|
||||
persona=card,
|
||||
state=kwargs["state"],
|
||||
session_no=int(kwargs["session_no"]),
|
||||
prev_rapport_credit=float(kwargs["carry_rapport"]),
|
||||
)
|
||||
durable_sessions[created.session_id] = created
|
||||
return created
|
||||
|
||||
def close_background(coro: object) -> None:
|
||||
close = getattr(coro, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"get_catalog_persona",
|
||||
AsyncMock(return_value=catalog_persona),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"get_case_context",
|
||||
AsyncMock(return_value=case_context),
|
||||
),
|
||||
patch.object(
|
||||
sessions,
|
||||
"_build_seed_recall",
|
||||
AsyncMock(return_value=memory.RecallContext()),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"create_session",
|
||||
create_next_session,
|
||||
),
|
||||
patch.object(sessions.asyncio, "create_task", close_background),
|
||||
):
|
||||
started = await sessions.start_session(
|
||||
sessions.SessionStartRequest(persona_code=card.code),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertNotEqual(started.session_id, old_session.session_id)
|
||||
self.assertEqual(started.session_id, next_session_id)
|
||||
self.assertEqual(started.case_id, case_id)
|
||||
self.assertEqual(started.session_no, 2)
|
||||
next_session = durable_sessions[next_session_id]
|
||||
persisted_turns: list[TurnRecord] = []
|
||||
|
||||
async def persist_turn(**kwargs: object) -> bool:
|
||||
turn = kwargs["turn"]
|
||||
assert isinstance(turn, TurnRecord)
|
||||
persisted_turns.append(turn)
|
||||
return True
|
||||
|
||||
async def successful_turn(
|
||||
ctx: orchestrator.TurnContext,
|
||||
_engine: object,
|
||||
**_kwargs: object,
|
||||
) -> orchestrator.TurnResult:
|
||||
assert ctx.state_after is not None
|
||||
return orchestrator.TurnResult(
|
||||
turn_seq=ctx.state_after.turn_seq,
|
||||
stage=ctx.state_after.stage.value,
|
||||
effective_openness=ctx.state_after.effective_openness,
|
||||
client_reply="조금 더 이야기해볼게요.",
|
||||
safety_flagged=False,
|
||||
state_after=ctx.state_after,
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=next_session),
|
||||
),
|
||||
patch.object(
|
||||
sessions.orchestrator,
|
||||
"run_turn_generate",
|
||||
successful_turn,
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"append_turn",
|
||||
persist_turn,
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"update_state",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
patch.object(
|
||||
sessions.rupture_runtime,
|
||||
"schedule_session_scan",
|
||||
),
|
||||
):
|
||||
first_turn = await sessions.submit_turn(
|
||||
next_session_id,
|
||||
sessions.TurnRequest(text="지난 이야기부터 이어가도 괜찮을까요?"),
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertEqual(first_turn.client_reply, "조금 더 이야기해볼게요.")
|
||||
self.assertEqual([turn.speaker for turn in persisted_turns], ["counselor", "client"])
|
||||
self.assertEqual([turn.speaker for turn in next_session.turns], ["counselor", "client"])
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=old_session),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"load_session_evaluation",
|
||||
AsyncMock(return_value=(old_evaluation, True)),
|
||||
),
|
||||
patch.object(
|
||||
sessions.session_persistence,
|
||||
"load_case_worksheet",
|
||||
AsyncMock(return_value=(None, True)),
|
||||
),
|
||||
):
|
||||
old_review = await sessions.get_session_review(
|
||||
old_session.session_id,
|
||||
principal,
|
||||
)
|
||||
|
||||
self.assertFalse(old_review.reviewReady)
|
||||
self.assertTrue(old_review.degraded)
|
||||
self.assertEqual(old_review.supervisorState, "평가 실패")
|
||||
self.assertIn("AI 평가 재시도가 필요합니다", old_review.summary)
|
||||
|
||||
teacher = Principal(
|
||||
user_id="00000000-0000-0000-0000-000000000202",
|
||||
role=Role.TEACHER,
|
||||
cohort_ids=[],
|
||||
email="teacher@hs.ac.kr",
|
||||
display_name="Teacher",
|
||||
)
|
||||
retry_save = AsyncMock(return_value=True)
|
||||
with (
|
||||
patch.object(
|
||||
eval_routes,
|
||||
"_load_session_or_404",
|
||||
AsyncMock(return_value=old_session),
|
||||
),
|
||||
patch.object(
|
||||
eval_routes.evaluator,
|
||||
"evaluate_session",
|
||||
AsyncMock(side_effect=EngineError("controlled evaluator 500")),
|
||||
),
|
||||
patch.object(
|
||||
eval_routes.session_persistence,
|
||||
"save_session_evaluation",
|
||||
retry_save,
|
||||
),
|
||||
):
|
||||
with self.assertRaises(HTTPException) as retry_error:
|
||||
await eval_routes.reevaluate_session(
|
||||
old_session.session_id,
|
||||
eval_routes.ReevaluateRequest(scope="session_end"),
|
||||
teacher,
|
||||
)
|
||||
|
||||
self.assertEqual(retry_error.exception.status_code, 503)
|
||||
retry_save.assert_awaited_once()
|
||||
retry_write = retry_save.await_args.args[0]
|
||||
self.assertEqual(retry_write.status, "error")
|
||||
|
||||
async def test_append_turn_writes_provider_events_to_db(self) -> None:
|
||||
class FakeConn:
|
||||
def __init__(self) -> None:
|
||||
|
|
@ -257,12 +545,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
persona_version=1,
|
||||
case_id="00000000-0000-0000-0000-000000000202",
|
||||
goal_stages=goals,
|
||||
learner_feedback_enabled=False,
|
||||
)
|
||||
|
||||
self.assertIsNotNone(created)
|
||||
self.assertIsInstance(conn.session_insert_args[11], list)
|
||||
self.assertEqual(conn.session_insert_args[11], goals)
|
||||
self.assertNotIsInstance(conn.session_insert_args[11], str)
|
||||
self.assertFalse(conn.session_insert_args[12])
|
||||
assert created is not None
|
||||
self.assertFalse(created.learner_feedback_enabled)
|
||||
|
||||
async def test_generate_turn_engine_failure_does_not_append_learner_turn(
|
||||
self,
|
||||
|
|
@ -887,7 +1179,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertTrue(response.next_utterance)
|
||||
self.assertTrue(response.sources)
|
||||
self.assertEqual(
|
||||
response.sources[0].source_id, "workbook_0615_case_conceptualization"
|
||||
response.sources[0].source_id, "official_counseling_guideline_seed"
|
||||
)
|
||||
self.assertNotIn(
|
||||
"workbook_0615_case_conceptualization",
|
||||
{source.source_id for source in response.sources},
|
||||
)
|
||||
history = await sessions.list_live_coach_history(sess.session_id, principal)
|
||||
self.assertEqual(history.source, "runtime")
|
||||
|
|
@ -992,6 +1288,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
"citation": "허가된 공식 지침 요약",
|
||||
},
|
||||
source_id="official_suicide_risk_guidelines",
|
||||
license_class="B",
|
||||
external_llm_ok=True,
|
||||
)
|
||||
],
|
||||
policy_name="evaluator:k=supervisor_pattern:s<=2",
|
||||
|
|
@ -1029,6 +1327,8 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(source.source_type, "official_guideline_summary")
|
||||
self.assertEqual(source.version, "2026-06-15")
|
||||
self.assertEqual(source.citation, "허가된 공식 지침 요약")
|
||||
self.assertEqual(source.license_class, "B")
|
||||
self.assertTrue(source.external_llm_ok)
|
||||
query = retrieve_grounding.await_args.kwargs["query"]
|
||||
self.assertNotIn("박민수", query)
|
||||
self.assertIn("[NAME]에게", query)
|
||||
|
|
@ -1146,6 +1446,98 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertTrue(official.citation)
|
||||
engine.generate.assert_called_once()
|
||||
|
||||
async def test_live_coach_prompt_uses_client_role_token_not_persona_name(
|
||||
self,
|
||||
) -> None:
|
||||
item = live_coach.LiveCoachInput(
|
||||
session_id="masked-coach-session",
|
||||
turn_seq=1,
|
||||
stage="rapport",
|
||||
effective_openness=0.25,
|
||||
theory_mode="humanistic",
|
||||
persona_code="P1",
|
||||
persona_name="서연",
|
||||
learner_text="오늘 어떤 마음으로 오셨어요?",
|
||||
client_reply="조금 긴장돼요.",
|
||||
prior_coach=[{"title": "서연에게 감정을 반영하세요", "focus": "emotion"}],
|
||||
)
|
||||
|
||||
prompt = "\n".join(
|
||||
message.content for message in live_coach._messages(item, grounding=[])
|
||||
)
|
||||
|
||||
self.assertIn("[내담자] [CLIENT] (P1)", prompt)
|
||||
self.assertIn("[CLIENT]에게 감정을 반영하세요", prompt)
|
||||
self.assertNotIn("서연", prompt)
|
||||
|
||||
async def test_live_coach_structured_output_masks_client_name_before_storage(
|
||||
self,
|
||||
) -> None:
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
engine_response = GenerateResponse(
|
||||
text="",
|
||||
model="fake-live-coach-model",
|
||||
provider="fake-provider",
|
||||
tokens_in=12,
|
||||
tokens_out=9,
|
||||
cost_usd=0.01,
|
||||
structured={
|
||||
"tone": "pos",
|
||||
"focus": "emotion",
|
||||
"title": "서연의 감정을 반영하세요",
|
||||
"message": "서연이 말한 긴장을 한 번 더 따라가세요.",
|
||||
"next_utterance": "서연님, 그 긴장이 언제 가장 커지나요?",
|
||||
"rationale": "서연의 표현을 그대로 짚으면 초점이 선명해집니다.",
|
||||
"safety_note": "서연의 안전 신호도 확인하세요.",
|
||||
},
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(
|
||||
sessions,
|
||||
"_retrieve_live_coach_grounding",
|
||||
AsyncMock(return_value=[]),
|
||||
),
|
||||
patch.object(
|
||||
sessions.engine_client,
|
||||
"generate",
|
||||
AsyncMock(return_value=engine_response),
|
||||
),
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"record_llm_call_audit",
|
||||
AsyncMock(return_value=True),
|
||||
),
|
||||
):
|
||||
response = await sessions.live_coach_turn(
|
||||
sess.session_id,
|
||||
sessions.LiveCoachRequest(
|
||||
learner_text="그 마음이 컸겠네요.",
|
||||
client_reply="네, 조금 긴장돼요.",
|
||||
turn_seq=1,
|
||||
),
|
||||
principal,
|
||||
)
|
||||
|
||||
rendered = " ".join(
|
||||
value
|
||||
for value in (
|
||||
response.title,
|
||||
response.message,
|
||||
response.next_utterance,
|
||||
response.rationale,
|
||||
response.safety_note,
|
||||
)
|
||||
if value
|
||||
)
|
||||
self.assertNotIn("서연", rendered)
|
||||
self.assertIn("[CLIENT]", rendered)
|
||||
history = await sessions.list_live_coach_history(sess.session_id, principal)
|
||||
persisted = history.events[0].suggestion.model_dump_json()
|
||||
self.assertNotIn("서연", persisted)
|
||||
self.assertIn("[CLIENT]", persisted)
|
||||
|
||||
async def test_start_session_uses_stable_case_context_and_seed_recall(self) -> None:
|
||||
principal = _principal()
|
||||
card = persona_service.P1
|
||||
|
|
@ -1448,6 +1840,38 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(events[1].data["tokens_out"], 7)
|
||||
self.assertEqual(events[1].data["cost_usd"], 0.034567)
|
||||
|
||||
async def test_run_turn_stream_rejects_clean_eof_without_gateway_done(self) -> None:
|
||||
class FakeStreamEngine:
|
||||
engine_mode = "claude_cli"
|
||||
default_model = None
|
||||
|
||||
async def stream(self, req):
|
||||
yield "event: token"
|
||||
yield 'data: {"text":"완료 전 부분 응답"}'
|
||||
|
||||
async def stream_packets(self, req):
|
||||
async for packet in _decoded_stream_packets(self, req):
|
||||
yield packet
|
||||
|
||||
principal = _principal()
|
||||
sess = _session(principal)
|
||||
ctx = orchestrator.prepare_turn(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
card=sess.persona,
|
||||
state=sess.state,
|
||||
learner_text="완료 이벤트 없는 스트림 테스트",
|
||||
memory=orchestrator.TurnMemory(recent_turns=[]),
|
||||
)
|
||||
|
||||
events = [
|
||||
event
|
||||
async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type]
|
||||
]
|
||||
|
||||
self.assertEqual([event.event for event in events], ["error"])
|
||||
self.assertEqual(events[0].data["detail"], "client_stream_incomplete")
|
||||
|
||||
async def test_run_turn_stream_treats_gateway_error_event_as_error(self) -> None:
|
||||
class FakeStreamEngine:
|
||||
engine_mode = "claude_cli"
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ from __future__ import annotations
|
|||
|
||||
from enum import Enum
|
||||
import logging
|
||||
from typing import Optional
|
||||
from typing import Any, Optional
|
||||
|
||||
from . import db, session_persistence
|
||||
from .deps import Principal
|
||||
|
|
@ -27,6 +27,28 @@ _LIVE_COACH_RECHARGE_MIN_OPENNESS_GAIN = 0.01
|
|||
_LIVE_COACH_PACING_RECHARGE_EVERY_TURNS = 6
|
||||
|
||||
|
||||
def _role_mask_evaluation_value(value: Any, sess: InProcSession) -> Any:
|
||||
"""Role-tokenize evaluator text before normalized rows or cache can persist it."""
|
||||
|
||||
if isinstance(value, str):
|
||||
return guardrail.mask_role_identities(
|
||||
value,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
synthetic_generated=True,
|
||||
).text_masked
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
key: _role_mask_evaluation_value(child, sess)
|
||||
for key, child in value.items()
|
||||
}
|
||||
if isinstance(value, list):
|
||||
return [_role_mask_evaluation_value(child, sess) for child in value]
|
||||
if isinstance(value, tuple):
|
||||
return [_role_mask_evaluation_value(child, sess) for child in value]
|
||||
return value
|
||||
|
||||
|
||||
class SessionAccessError(str, Enum):
|
||||
NOT_FOUND = "not_found"
|
||||
FORBIDDEN = "forbidden"
|
||||
|
|
@ -114,7 +136,12 @@ async def record_completed_turn(
|
|||
stage=stage_label(ctx.state_after.stage),
|
||||
text=ctx.learner_text_raw,
|
||||
text_masked=ctx.learner_text_masked,
|
||||
evaluation=result.evaluation,
|
||||
evaluation=_role_mask_evaluation_value(result.evaluation, sess),
|
||||
)
|
||||
if counselor_turn is not None:
|
||||
learner_turn.evaluation = _role_mask_evaluation_value(
|
||||
learner_turn.evaluation,
|
||||
sess,
|
||||
)
|
||||
await append_completed_turn(
|
||||
sess,
|
||||
|
|
@ -125,7 +152,12 @@ async def record_completed_turn(
|
|||
# result.client_reply는 출력 가드레일을 통과한 합성 내담자 발화다.
|
||||
# 입력용의 광범위한 성씨 휴리스틱을 다시 적용하면 "모르겠는데요" 같은
|
||||
# 일반 용언이 [NAME]으로 오탐되어 저장·재개 화면에 누출된다.
|
||||
client_mask = guardrail.mask_synthetic_generated_pii(result.client_reply)
|
||||
client_mask = guardrail.mask_role_identities(
|
||||
result.client_reply,
|
||||
counselor_identity=sess.learner_label,
|
||||
client_identity=sess.persona.display_name,
|
||||
synthetic_generated=True,
|
||||
)
|
||||
await append_completed_turn(
|
||||
sess,
|
||||
TurnRecord(
|
||||
|
|
|
|||
|
|
@ -297,6 +297,75 @@ class GatewayAuthenticationTest(unittest.TestCase):
|
|||
self.assertEqual(async_client_cls.call_args.kwargs["headers"], {})
|
||||
|
||||
|
||||
class EngineClientReadinessTest(unittest.IsolatedAsyncioTestCase):
|
||||
@staticmethod
|
||||
def _response(status_code, payload):
|
||||
return engine_client.httpx.Response(
|
||||
status_code,
|
||||
json=payload,
|
||||
request=engine_client.httpx.Request("GET", "http://127.0.0.1:9099/ready"),
|
||||
)
|
||||
|
||||
async def test_health_fails_when_dedicated_live_client_provider_is_unready(self):
|
||||
client = engine_client.EngineClient("http://127.0.0.1:9099")
|
||||
client.engine_mode = "agy_cli"
|
||||
client.default_model = "gemini-3.6-flash-high"
|
||||
client.default_reasoning_effort = "high"
|
||||
client.live_client_provider = "claude_cli"
|
||||
client._client = AsyncMock()
|
||||
client._client.get = AsyncMock(
|
||||
side_effect=[
|
||||
self._response(200, {"ok": True, "detail": "agy ready"}),
|
||||
self._response(503, {"ok": False, "detail": "claude login required"}),
|
||||
]
|
||||
)
|
||||
|
||||
detail = await client.health_detail()
|
||||
|
||||
self.assertFalse(detail["ok"])
|
||||
self.assertEqual(detail["default_engine"]["provider"], "agy_cli")
|
||||
self.assertTrue(detail["default_engine"]["ok"])
|
||||
self.assertEqual(detail["live_client_engine"]["provider"], "claude_cli")
|
||||
self.assertFalse(detail["live_client_engine"]["ok"])
|
||||
self.assertIn("실시간 내담자 공급자 claude_cli", detail["detail"])
|
||||
calls = client._client.get.await_args_list
|
||||
self.assertEqual(calls[0].kwargs["params"]["provider"], "agy_cli")
|
||||
self.assertEqual(calls[0].kwargs["params"]["model"], "gemini-3.6-flash-high")
|
||||
self.assertEqual(calls[1].kwargs["params"], {"provider": "claude_cli"})
|
||||
|
||||
async def test_health_reuses_one_probe_when_live_and_default_provider_match(self):
|
||||
client = engine_client.EngineClient("http://127.0.0.1:9099")
|
||||
client.engine_mode = "claude_cli"
|
||||
client.live_client_provider = "claude_cli"
|
||||
client._client = AsyncMock()
|
||||
client._client.get = AsyncMock(
|
||||
return_value=self._response(200, {"ok": True, "detail": "ready"})
|
||||
)
|
||||
|
||||
detail = await client.health_detail()
|
||||
|
||||
self.assertTrue(detail["ok"])
|
||||
client._client.get.assert_awaited_once()
|
||||
|
||||
async def test_gateway_liveness_does_not_replace_provider_readiness(self):
|
||||
client = engine_client.EngineClient("http://127.0.0.1:9099")
|
||||
client.engine_mode = "claude_cli"
|
||||
client.live_client_provider = "claude_cli"
|
||||
client._client = AsyncMock()
|
||||
client._client.get = AsyncMock(
|
||||
side_effect=[
|
||||
self._response(404, {"detail": "missing"}),
|
||||
self._response(200, {"status": "ok"}),
|
||||
]
|
||||
)
|
||||
|
||||
detail = await client.health_detail()
|
||||
|
||||
self.assertFalse(detail["ok"])
|
||||
self.assertIn("공급자 준비상태 엔드포인트", detail["detail"])
|
||||
self.assertEqual(client._client.get.await_count, 2)
|
||||
|
||||
|
||||
class GatewayModelTest(unittest.TestCase):
|
||||
def test_contract_owns_gateway_default_model_sentinel(self):
|
||||
self.assertEqual(contract.ENGINE_GATEWAY_DEFAULT_MODEL_SENTINEL, "gateway-default")
|
||||
|
|
|
|||
14
apps/web/design.md
Normal file
14
apps/web/design.md
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
# Vignette Web 디자인 결정
|
||||
|
||||
전역 디자인의 권위 원본은 [`../../docs/DESIGN_CONCEPT.md`](../../docs/DESIGN_CONCEPT.md)다.
|
||||
이 문서는 `apps/web`에서 이번 변경이 선택한 국소 결정만 기록하며, 색·타이포·간격·radius 토큰을 재정의하지 않는다.
|
||||
|
||||
## 2026-08-27 개선관리 반영
|
||||
|
||||
- 관리자 프로토콜 관리는 새 독립 화면이 아니라 기존 `/admin/access`의 접근 관리 흐름 안에 둔다.
|
||||
- 기존 관리자 카드·탭·입력·상태 표면을 재사용하고, 새 색·글꼴·장식 모션은 추가하지 않는다.
|
||||
- 프로토콜 등록·활성화·퇴역 조작은 키보드로 접근할 수 있고 독립 컨트롤은 최소 44×44px을 유지한다.
|
||||
- 학습자 AI 피드백이 꺼진 회기 리뷰는 AI 파생 카드를 마운트하지 않는다. 축어록, 학습자 입력, 동의·철회·삭제·원음 재생 같은 개인정보 권리 표면은 기존 위계와 위치를 유지한다.
|
||||
- 320px과 모바일 프로젝트에서도 문서 가로 넘침이 없어야 하며, 오류·재시도 상태는 기존 관리자/리뷰 알림 패턴을 따른다.
|
||||
|
||||
국소 구현 소유자는 `src/pages/Admin.tsx`, `src/pages/admin/admin-console.css`, `src/pages/SessionReview.tsx`, `src/pages/session-review/`다. 공통 토큰과 앱 셸은 각각 `src/styles/tokens.css`, `src/components/shell/`의 소유권을 유지한다.
|
||||
|
|
@ -30,6 +30,7 @@ interface AdminManagedUser {
|
|||
email: string;
|
||||
display_name: string;
|
||||
role: "learner" | "teacher" | "admin";
|
||||
learner_feedback_enabled: boolean;
|
||||
account_status: "pending" | "approved" | "suspended";
|
||||
affiliation: string;
|
||||
cohort_ids: string[];
|
||||
|
|
@ -676,7 +677,9 @@ async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
|
|||
|
||||
const clippedControls = await form.evaluate((element) => {
|
||||
const formRect = element.getBoundingClientRect();
|
||||
const controls = Array.from(element.querySelectorAll<HTMLElement>("input, select, button"));
|
||||
const controls = Array.from(
|
||||
element.querySelectorAll<HTMLElement>("input, select, output, button"),
|
||||
);
|
||||
|
||||
return controls
|
||||
.map((control) => {
|
||||
|
|
@ -1398,7 +1401,7 @@ test.describe("admin route", () => {
|
|||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
|
||||
test("allows an admin to manage real server-known users", async ({ page, browser }, testInfo) => {
|
||||
test.setTimeout(60_000);
|
||||
await signInAsAdmin(page);
|
||||
|
||||
|
|
@ -1427,16 +1430,19 @@ test.describe("admin route", () => {
|
|||
await sessionHeader.getByRole("button").click();
|
||||
await expect(sessionHeader).toHaveAttribute("aria-sort", "descending");
|
||||
|
||||
await page.getByRole("tab", { name: "사용자 등록" }).click();
|
||||
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
|
||||
await page.getByLabel("새 사용자 이메일").fill(email);
|
||||
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
|
||||
await page.getByLabel("새 사용자 역할").selectOption("learner");
|
||||
await page.getByLabel("새 사용자 승인 상태").selectOption("pending");
|
||||
await expect(page.getByLabel("새 사용자 승인 상태")).toHaveAttribute(
|
||||
"data-value",
|
||||
"pending",
|
||||
);
|
||||
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
|
||||
|
||||
const createPromise = page.waitForResponse(isAdminUserCreate);
|
||||
const reloadAfterCreatePromise = page.waitForResponse(isAdminUsersResponse);
|
||||
await page.getByRole("button", { name: "사용자 등록" }).click();
|
||||
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
|
||||
const createResponse = await createPromise;
|
||||
await expectResponseOk(createResponse);
|
||||
await expectResponseOk(await reloadAfterCreatePromise);
|
||||
|
|
@ -1461,6 +1467,31 @@ test.describe("admin route", () => {
|
|||
await expect(approvalCard).toContainText("승인 대기");
|
||||
await expectVisibleButtonsFit(page, ".vgops-approval__actions .vg-btn", "admin approval buttons");
|
||||
|
||||
const pendingContext = await browser.newContext({
|
||||
baseURL: String(testInfo.project.use.baseURL),
|
||||
});
|
||||
try {
|
||||
const pendingPage = await pendingContext.newPage();
|
||||
const pendingLogin = await pendingPage.request.post("/api/auth/dev-login", {
|
||||
data: {
|
||||
email,
|
||||
role: "learner",
|
||||
display_name: displayName,
|
||||
},
|
||||
});
|
||||
await expectResponseOk(pendingLogin);
|
||||
expect(await pendingLogin.json()).toMatchObject({
|
||||
email,
|
||||
account_status: "pending",
|
||||
approval_required: true,
|
||||
});
|
||||
|
||||
const blockedPersonas = await pendingPage.request.get("/api/personas");
|
||||
expect(blockedPersonas.status()).toBe(403);
|
||||
expect(await blockedPersonas.json()).toMatchObject({ detail: "account_pending" });
|
||||
await pendingPage.goto("/learn");
|
||||
await expect(pendingPage).toHaveURL(/\/pending$/);
|
||||
|
||||
const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id));
|
||||
await approvalCard.getByRole("button", { name: "승인" }).click();
|
||||
const approveResponse = await approvePromise;
|
||||
|
|
@ -1473,6 +1504,18 @@ test.describe("admin route", () => {
|
|||
});
|
||||
await expect(approvalCard).toHaveCount(0);
|
||||
|
||||
const approvedMe = await pendingPage.request.get("/api/auth/me");
|
||||
await expectResponseOk(approvedMe);
|
||||
expect(await approvedMe.json()).toMatchObject({
|
||||
email,
|
||||
account_status: "approved",
|
||||
approval_required: false,
|
||||
});
|
||||
await expectResponseOk(await pendingPage.request.get("/api/personas"));
|
||||
} finally {
|
||||
await pendingContext.close();
|
||||
}
|
||||
|
||||
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
||||
await page.getByLabel("사용자 검색").fill(email);
|
||||
const card = page.locator(".vgops-user-table tbody tr").filter({ hasText: email });
|
||||
|
|
@ -1484,6 +1527,7 @@ test.describe("admin route", () => {
|
|||
const nextName = `교수자 ${testInfo.project.name}`;
|
||||
const nameInput = card.getByLabel(`${email} 표시 이름`);
|
||||
const roleSelect = card.getByLabel(`${email} 역할`);
|
||||
const feedbackToggle = card.getByLabel(`${email} 학습자 AI 피드백`);
|
||||
const cohortInput = card.getByLabel(`${email} 코호트`);
|
||||
const saveButton = card.getByRole("button", { name: "저장" });
|
||||
const nextCohort = `cohort-${testInfo.project.name}`;
|
||||
|
|
@ -1492,6 +1536,9 @@ test.describe("admin route", () => {
|
|||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
await roleSelect.selectOption("teacher");
|
||||
await expect(roleSelect).toHaveValue("teacher");
|
||||
await expect(feedbackToggle).toBeChecked();
|
||||
await feedbackToggle.uncheck();
|
||||
await expect(feedbackToggle).not.toBeChecked();
|
||||
await cohortInput.fill(nextCohort);
|
||||
await expect(cohortInput).toHaveValue(nextCohort);
|
||||
await page.evaluate(() => new Promise(requestAnimationFrame));
|
||||
|
|
@ -1507,14 +1554,23 @@ test.describe("admin route", () => {
|
|||
email,
|
||||
display_name: nextName,
|
||||
role: "teacher",
|
||||
learner_feedback_enabled: false,
|
||||
account_status: "approved",
|
||||
cohort_ids: [nextCohort],
|
||||
});
|
||||
|
||||
await expect(nameInput).toHaveValue(nextName);
|
||||
await expect(card).toContainText("교수자");
|
||||
await expect(feedbackToggle).not.toBeChecked();
|
||||
await expect(cohortInput).toHaveValue(nextCohort);
|
||||
|
||||
const persistedUsersResponse = await page.request.get("/api/admin/users");
|
||||
await expectResponseOk(persistedUsersResponse);
|
||||
const persistedUsers = (await persistedUsersResponse.json()) as AdminUsersResponse;
|
||||
expect(
|
||||
persistedUsers.users.find((user) => user.user_id === created.user_id),
|
||||
).toMatchObject({ learner_feedback_enabled: false });
|
||||
|
||||
const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id));
|
||||
await card.getByRole("button", { name: "비활성화" }).click();
|
||||
const deleteResponse = await deletePromise;
|
||||
|
|
@ -1663,7 +1719,7 @@ test.describe("admin route", () => {
|
|||
await signInAsAdmin(page);
|
||||
|
||||
const users = await openAdminAndReadUsers(page);
|
||||
await page.getByRole("tab", { name: "사용자 등록" }).click();
|
||||
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
|
||||
const layout = await page.evaluate(() => {
|
||||
const form = document.querySelector<HTMLElement>(".vgops-user-create");
|
||||
if (!form) throw new Error("admin user create form was not rendered");
|
||||
|
|
@ -1702,7 +1758,7 @@ test.describe("admin route", () => {
|
|||
for (const width of [861, 900, 1024]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await openAdminAndReadUsers(page);
|
||||
await page.getByRole("tab", { name: "사용자 등록" }).click();
|
||||
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
|
||||
|
||||
await expectNoHorizontalOverflow(page);
|
||||
await expectCreateUserControlsFit(page, width);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import {
|
||||
FILLED_REVIEW_SESSION_ID,
|
||||
FILLED_REVIEW_TURN_IDS,
|
||||
filledReviewResponse,
|
||||
routeFilledSessionReview,
|
||||
routePrepostMeasures,
|
||||
|
|
@ -58,7 +59,7 @@ function routeReviewUser(page: Page, role: Role) {
|
|||
|
||||
function measurements(): PulseMeasurement[] {
|
||||
const evidence = {
|
||||
turn_id: "t6",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[5],
|
||||
seq: 6,
|
||||
speaker: "client",
|
||||
text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
|
||||
|
|
@ -170,7 +171,7 @@ test.describe("G1 치료 동맹 펄스", () => {
|
|||
expect(submittedBody).toEqual({
|
||||
checkpoint: "post",
|
||||
scores: { goal: 0.75, task: 0.5, bond: 1 },
|
||||
evidence_turn_ids: ["t1"],
|
||||
evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]],
|
||||
});
|
||||
await expect(page.getByLabel("치료 동맹 관점 비교")).toBeVisible({
|
||||
timeout: 5_000,
|
||||
|
|
@ -316,7 +317,7 @@ test.describe("G1 치료 동맹 펄스", () => {
|
|||
await expect(supervisor.getByText("교수자 판정을 원장에 추가했습니다.")).toBeVisible();
|
||||
expect(submittedBody).toEqual({
|
||||
scores: { goal: 0.75, task: 0.5, bond: 0.75 },
|
||||
evidence_turn_ids: ["t1"],
|
||||
evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]],
|
||||
note: "목표 합의는 안정적이지만 과업 속도는 다음 지도에서 다시 확인합니다.",
|
||||
});
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
|
|
|||
|
|
@ -68,7 +68,8 @@ test.describe("auth domain policy", () => {
|
|||
|
||||
const googleButtons = page.locator(".lg-obtn");
|
||||
await expect(googleButtons).toHaveCount(2);
|
||||
await expect(page.locator(".lg-policy b")).toContainText(config.allowed_email_domains);
|
||||
expect(config.allowed_email_domains).toEqual([]);
|
||||
await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
|
||||
const redirectHost = new URL(config.redirect_uri).hostname;
|
||||
const devOAuthUnavailable =
|
||||
config.dev_login_enabled &&
|
||||
|
|
@ -233,7 +234,7 @@ test.describe("auth domain policy", () => {
|
|||
google_oauth_configured: true,
|
||||
dev_login_enabled: false,
|
||||
});
|
||||
expect(config.allowed_email_domains).toEqual(expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]));
|
||||
expect(config.allowed_email_domains).toEqual([]);
|
||||
|
||||
await page.goto(`${publicBase}/login`, { waitUntil: "domcontentloaded" });
|
||||
await page.waitForLoadState("networkidle", { timeout: 15_000 }).catch(() => undefined);
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ const READY_AUTH_CONFIG = {
|
|||
login_path: "/auth/login?provider=saml",
|
||||
},
|
||||
],
|
||||
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
|
||||
allowed_email_domains: [],
|
||||
redirect_uri: "http://127.0.0.1:8000/auth/callback",
|
||||
dev_login_enabled: true,
|
||||
};
|
||||
|
|
@ -153,7 +153,7 @@ test.describe("full sweep auth", () => {
|
|||
});
|
||||
|
||||
// 검증 checklist: auth-login-generic-google-button
|
||||
test("secondary Google button shows the second allowed domain and starts the same google OAuth flow", async ({
|
||||
test("both Google buttons advertise unrestricted accounts and start the same OAuth flow", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockUnauthenticatedMe(page);
|
||||
|
|
@ -175,11 +175,9 @@ test.describe("full sweep auth", () => {
|
|||
|
||||
const secondary = page.locator(".lg-obtn.secondary");
|
||||
await expect(secondary).toBeEnabled();
|
||||
await expect(secondary).toContainText("Google 계정으로 계속");
|
||||
// 서브라벨은 두 번째 허용 도메인을 표시한다.
|
||||
await expect(secondary.locator(".sub")).toHaveText("@twentyoz.kr");
|
||||
// primary 버튼과 동일하게 첫 번째 도메인은 primary 서브라벨에 있다.
|
||||
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
|
||||
await expect(secondary).toContainText("다른 Google 계정 선택");
|
||||
await expect(secondary.locator(".sub")).toHaveText("이메일 확인 후 바로 시작");
|
||||
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("모든 Google 계정");
|
||||
|
||||
// 클릭 시 primary와 동일한 provider=google 시작 URL로 이동한다.
|
||||
await Promise.all([
|
||||
|
|
@ -189,7 +187,7 @@ test.describe("full sweep auth", () => {
|
|||
});
|
||||
|
||||
// 검증 checklist: auth-login-brand-domain-badges
|
||||
test("brand panel domain badges distinguish checking, unset, and loaded states", async ({
|
||||
test("brand panel Google policy badge distinguishes checking, unset, and ready states", async ({
|
||||
page,
|
||||
}) => {
|
||||
await mockUnauthenticatedMe(page);
|
||||
|
|
@ -198,7 +196,7 @@ test.describe("full sweep auth", () => {
|
|||
/* 응답을 보류해 조회 중 상태를 고정한다 */
|
||||
});
|
||||
await page.goto("/login");
|
||||
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
|
||||
await expect(page.locator(".lg-policy")).toContainText("Google 로그인");
|
||||
await expect(page.locator(".lg-policy b")).toHaveText("확인 중");
|
||||
|
||||
// 2) 설정 조회 실패: "설정 필요" 배지.
|
||||
|
|
@ -213,14 +211,11 @@ test.describe("full sweep auth", () => {
|
|||
await page.goto("/login");
|
||||
await expect(page.locator(".lg-policy b")).toHaveText("설정 필요");
|
||||
|
||||
// 3) 정상 로드: allowed_email_domains를 배지로 나열.
|
||||
// 3) 정상 로드: 모든 provider-verified Google 계정을 허용한다.
|
||||
await page.unroute(CONFIG_PATTERN);
|
||||
await fulfillConfig(page, READY_AUTH_CONFIG);
|
||||
await page.goto("/login");
|
||||
const badges = page.locator(".lg-policy b");
|
||||
await expect(badges).toHaveCount(2);
|
||||
await expect(badges.nth(0)).toHaveText("hs.ac.kr");
|
||||
await expect(badges.nth(1)).toHaveText("twentyoz.kr");
|
||||
await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
|
||||
});
|
||||
|
||||
// 검증 checklist: auth-onboarding-avatar-upload
|
||||
|
|
|
|||
|
|
@ -196,11 +196,12 @@ test.describe("learner app shell and session launcher", () => {
|
|||
await expect(launcher).toBeVisible();
|
||||
await expect(launcher.getByRole("option")).toHaveCount(personas.length, { timeout: 15_000 });
|
||||
for (const persona of personas) {
|
||||
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
|
||||
const codePattern = new RegExp(`^${persona.code}(?:\\s|$)`);
|
||||
const option = launcher.getByRole("option", { name: codePattern });
|
||||
await expect(option).toBeVisible();
|
||||
// display_name 은 가운뎃점 3개 이상이면 이름줄과 주호소줄로 나뉘어 렌더된다
|
||||
// (2026-07-27 D3: 한 줄에 · 1개 제한). 이어 붙인 원문 대신 각 조각이 모두
|
||||
// 옵션 안에 있는지 확인해, 표시 방식이 바뀌어도 내용 누락만 잡히게 한다.
|
||||
const option = launcher.getByRole("option", { name: new RegExp(persona.code) });
|
||||
for (const part of persona.display_name.split("·").map((piece) => piece.trim()).filter(Boolean)) {
|
||||
await expect(option).toContainText(part);
|
||||
}
|
||||
|
|
|
|||
1145
apps/web/e2e/persona-db-lifecycle.spec.ts
Normal file
1145
apps/web/e2e/persona-db-lifecycle.spec.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -71,7 +71,7 @@ test.describe("public Google OAuth turn smoke", () => {
|
|||
expect(configResponse.ok(), await configResponse.text()).toBeTruthy();
|
||||
await expect(await configResponse.json()).toMatchObject({
|
||||
google_oauth_configured: true,
|
||||
allowed_email_domains: expect.arrayContaining(["hs.ac.kr", "twentyoz.kr"]),
|
||||
allowed_email_domains: [],
|
||||
dev_login_enabled: false,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
import {
|
||||
completeAlliancePreCheckpoint,
|
||||
|
|
@ -10,6 +11,8 @@ import {
|
|||
|
||||
interface SessionStartResponse {
|
||||
session_id: string;
|
||||
case_id: string;
|
||||
session_no: number;
|
||||
degraded: boolean;
|
||||
}
|
||||
|
||||
|
|
@ -173,6 +176,126 @@ interface PrepostMeasuresResponse {
|
|||
measures: PrepostMeasureItem[];
|
||||
}
|
||||
|
||||
interface ControlledFailureGateway {
|
||||
url: string;
|
||||
clientRequests: () => number;
|
||||
evaluatorRequests: () => number;
|
||||
close: () => Promise<void>;
|
||||
}
|
||||
|
||||
async function startControlledEvaluationFailureGateway(
|
||||
model: string,
|
||||
fallbackProvider: string,
|
||||
): Promise<ControlledFailureGateway> {
|
||||
let clientRequestCount = 0;
|
||||
let evaluatorRequestCount = 0;
|
||||
|
||||
const sendJson = (response: ServerResponse, status: number, payload: unknown) => {
|
||||
response.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
||||
response.end(JSON.stringify(payload));
|
||||
};
|
||||
const readJson = async (request: IncomingMessage) => {
|
||||
const chunks: Buffer[] = [];
|
||||
for await (const chunk of request) {
|
||||
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf8");
|
||||
return raw ? (JSON.parse(raw) as Record<string, unknown>) : {};
|
||||
};
|
||||
|
||||
const server = createServer(async (request, response) => {
|
||||
try {
|
||||
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
||||
const provider = url.searchParams.get("provider") || fallbackProvider;
|
||||
if (request.method === "GET" && url.pathname === "/v1/capabilities") {
|
||||
sendJson(response, 200, {
|
||||
provider,
|
||||
available: true,
|
||||
source: "live_cli",
|
||||
models: [
|
||||
{
|
||||
id: model,
|
||||
label: "REQ-007 controlled gateway",
|
||||
description: "client succeeds while evaluator returns HTTP 500",
|
||||
reasoning_efforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
|
||||
default_reasoning_effort: "high",
|
||||
is_default: true,
|
||||
},
|
||||
],
|
||||
default_model: model,
|
||||
default_reasoning_effort: "high",
|
||||
detail: "controlled gateway ready",
|
||||
fetched_at: Date.now() / 1000,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "GET" && ["/ready", "/health"].includes(url.pathname)) {
|
||||
sendJson(response, 200, {
|
||||
ok: true,
|
||||
status: "ok",
|
||||
detail: "controlled gateway ready",
|
||||
cached: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (request.method === "DELETE" && url.pathname.startsWith("/session/")) {
|
||||
sendJson(response, 200, { closed: true });
|
||||
return;
|
||||
}
|
||||
if (request.method === "POST" && url.pathname === "/v1/generate") {
|
||||
const payload = await readJson(request);
|
||||
if (payload.ai_role === "evaluator") {
|
||||
evaluatorRequestCount += 1;
|
||||
sendJson(response, 500, { detail: "controlled evaluator failure" });
|
||||
return;
|
||||
}
|
||||
clientRequestCount += 1;
|
||||
sendJson(response, 200, {
|
||||
text: "조금 더 이야기해볼게요.",
|
||||
model,
|
||||
provider: String(payload.provider || fallbackProvider),
|
||||
tokens_in: 7,
|
||||
tokens_out: 9,
|
||||
cost_usd: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
sendJson(response, 404, { detail: `unexpected controlled gateway path: ${url.pathname}` });
|
||||
} catch (error) {
|
||||
sendJson(response, 500, {
|
||||
detail: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onError = (error: Error) => reject(error);
|
||||
server.once("error", onError);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
server.off("error", onError);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
throw new Error("controlled gateway did not expose a TCP port");
|
||||
}
|
||||
|
||||
return {
|
||||
url: `http://127.0.0.1:${address.port}`,
|
||||
clientRequests: () => clientRequestCount,
|
||||
evaluatorRequests: () => evaluatorRequestCount,
|
||||
close: () =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => {
|
||||
if (error) reject(error);
|
||||
else resolve();
|
||||
});
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
|
||||
if (!response.ok()) {
|
||||
expect(response.ok(), await response.text()).toBeTruthy();
|
||||
|
|
@ -1357,6 +1480,158 @@ test.describe("session persistence", () => {
|
|||
}
|
||||
});
|
||||
|
||||
test("keeps the failed review durable while the next persona session accepts a stored turn @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await withGlobalEngineConfigLock("req-007-evaluation-failure-continuity", async () => {
|
||||
await signInAsAdmin(page);
|
||||
const engineResponse = await page.request.get("/api/admin/engine-config");
|
||||
await expectResponseOk(engineResponse);
|
||||
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
|
||||
expect(currentEngine.source, "REQ-007 continuity proof requires the real DB lane").toBe(
|
||||
"database",
|
||||
);
|
||||
expect(currentEngine.durable, "REQ-007 engine configuration must be durable").toBe(true);
|
||||
const gateway = await startControlledEvaluationFailureGateway(
|
||||
currentEngine.model,
|
||||
currentEngine.engine_mode,
|
||||
);
|
||||
|
||||
try {
|
||||
const controlledPatch = await page.request.patch("/api/admin/engine-config", {
|
||||
data: {
|
||||
engine_mode: currentEngine.engine_mode,
|
||||
engine_url: gateway.url,
|
||||
model: currentEngine.model,
|
||||
reasoning_effort: currentEngine.reasoning_effort,
|
||||
},
|
||||
});
|
||||
await expectResponseOk(controlledPatch);
|
||||
|
||||
await signInAsLearner(page);
|
||||
const persona = await fetchAvailablePersona(page);
|
||||
const firstStartResponse = await page.request.post("/api/sessions", {
|
||||
data: { persona_code: persona.code, theory_mode: "humanistic" },
|
||||
});
|
||||
await expectResponseOk(firstStartResponse);
|
||||
expect(firstStartResponse.status()).toBe(201);
|
||||
const first = (await firstStartResponse.json()) as SessionStartResponse;
|
||||
expect(first.degraded).toBe(false);
|
||||
|
||||
const firstTurnResponse = await page.request.post(
|
||||
`/api/sessions/${first.session_id}/turn`,
|
||||
{
|
||||
data: { text: "오늘 가장 버거웠던 순간부터 이야기해도 괜찮을까요?" },
|
||||
},
|
||||
);
|
||||
await expectResponseOk(firstTurnResponse);
|
||||
|
||||
const endResponse = await page.request.post(`/api/sessions/${first.session_id}/end`);
|
||||
await expectResponseOk(endResponse);
|
||||
expect(endResponse.status()).toBe(200);
|
||||
|
||||
const endedDetailResponse = await page.request.get(`/api/sessions/${first.session_id}`);
|
||||
await expectResponseOk(endedDetailResponse);
|
||||
const endedDetail = (await endedDetailResponse.json()) as SessionDetailResponse;
|
||||
expect(endedDetail.status).toBe("ended");
|
||||
expect(endedDetail.ended_at).toBeTruthy();
|
||||
expect(endedDetail.turns?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const nextStartResponse = await page.request.post("/api/sessions", {
|
||||
data: { persona_code: persona.code, theory_mode: "humanistic" },
|
||||
});
|
||||
await expectResponseOk(nextStartResponse);
|
||||
expect(nextStartResponse.status()).toBe(201);
|
||||
const next = (await nextStartResponse.json()) as SessionStartResponse;
|
||||
expect(next.degraded).toBe(false);
|
||||
expect(next.session_id).not.toBe(first.session_id);
|
||||
expect(next.case_id).toBe(first.case_id);
|
||||
expect(next.session_no).toBe(first.session_no + 1);
|
||||
|
||||
const nextTurnResponse = await page.request.post(
|
||||
`/api/sessions/${next.session_id}/turn`,
|
||||
{
|
||||
data: { text: "지난 회기의 이야기를 이어서 천천히 살펴볼까요?" },
|
||||
},
|
||||
);
|
||||
await expectResponseOk(nextTurnResponse);
|
||||
const nextTurn = (await nextTurnResponse.json()) as {
|
||||
client_reply?: string | null;
|
||||
output_error?: string | null;
|
||||
};
|
||||
expect(nextTurn.client_reply).toBe("조금 더 이야기해볼게요.");
|
||||
expect(nextTurn.output_error ?? null).toBeNull();
|
||||
|
||||
const nextDetailResponse = await page.request.get(`/api/sessions/${next.session_id}`);
|
||||
await expectResponseOk(nextDetailResponse);
|
||||
const nextDetail = (await nextDetailResponse.json()) as SessionDetailResponse;
|
||||
expect(nextDetail.status).toBe("active");
|
||||
expect(nextDetail.turns?.length ?? 0).toBeGreaterThanOrEqual(2);
|
||||
expect(gateway.clientRequests()).toBeGreaterThanOrEqual(2);
|
||||
await expect
|
||||
.poll(() => gateway.evaluatorRequests(), {
|
||||
timeout: 30_000,
|
||||
message: "the ended session must reach the controlled evaluator failure",
|
||||
})
|
||||
.toBeGreaterThan(0);
|
||||
|
||||
await signInAsTeacher(page);
|
||||
await expect
|
||||
.poll(async () => evaluationState(page, first.session_id), {
|
||||
timeout: 30_000,
|
||||
intervals: [250, 500, 1_000],
|
||||
message: "the evaluator HTTP 500 must become a durable error row",
|
||||
})
|
||||
.toMatch(/^error:.*:durable$/);
|
||||
|
||||
const reviewResponse = await page.request.get(`/api/sessions/${first.session_id}/review`);
|
||||
await expectResponseOk(reviewResponse);
|
||||
const review = (await reviewResponse.json()) as SessionReviewResponse;
|
||||
expect(review.reviewReady).toBe(false);
|
||||
expect(review.degraded).toBe(true);
|
||||
expect(review.supervisorState).toBe("평가 실패");
|
||||
expect(review.summary ?? "").toContain("AI 평가 재시도가 필요합니다");
|
||||
|
||||
await page.goto(`/teach/session/${first.session_id}/review`);
|
||||
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible({
|
||||
timeout: 15_000,
|
||||
});
|
||||
|
||||
const evaluatorRequestsBeforeRetry = gateway.evaluatorRequests();
|
||||
const retryResponse = await page.request.post(
|
||||
`/api/eval/sessions/${first.session_id}/reevaluate`,
|
||||
{ data: { scope: "session_end" } },
|
||||
);
|
||||
expect(retryResponse.status()).toBe(503);
|
||||
await expect
|
||||
.poll(() => gateway.evaluatorRequests(), { timeout: 10_000 })
|
||||
.toBeGreaterThan(evaluatorRequestsBeforeRetry);
|
||||
await expect
|
||||
.poll(async () => evaluationState(page, first.session_id), {
|
||||
timeout: 10_000,
|
||||
})
|
||||
.toMatch(/^error:.*:durable$/);
|
||||
} finally {
|
||||
try {
|
||||
await signInAsAdmin(page);
|
||||
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
|
||||
data: {
|
||||
engine_mode: currentEngine.engine_mode,
|
||||
engine_url: currentEngine.engine_url,
|
||||
model: currentEngine.model,
|
||||
reasoning_effort: currentEngine.reasoning_effort,
|
||||
},
|
||||
});
|
||||
await expectResponseOk(restoreResponse);
|
||||
} finally {
|
||||
await gateway.close();
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
test("finishes session end evaluation into a durable DB-backed teacher review @single-run", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
|
|
|||
|
|
@ -5,14 +5,25 @@ import type {
|
|||
UserPrepostMeasuresResponse,
|
||||
} from "../src/lib/api";
|
||||
|
||||
export const FILLED_REVIEW_SESSION_ID = "filled-review-visual";
|
||||
export const EMPTY_REVIEW_SESSION_ID = "empty-review-visual";
|
||||
export const FILLED_REVIEW_SESSION_ID =
|
||||
"00000000-0000-4000-8000-000000000110";
|
||||
export const EMPTY_REVIEW_SESSION_ID =
|
||||
"00000000-0000-4000-8000-000000000111";
|
||||
export const FILLED_REVIEW_TURN_IDS = [
|
||||
"00000000-0000-4000-8000-000000000001",
|
||||
"00000000-0000-4000-8000-000000000002",
|
||||
"00000000-0000-4000-8000-000000000003",
|
||||
"00000000-0000-4000-8000-000000000004",
|
||||
"00000000-0000-4000-8000-000000000005",
|
||||
"00000000-0000-4000-8000-000000000006",
|
||||
] as const;
|
||||
|
||||
export function filledReviewResponse(
|
||||
sessionId: string = FILLED_REVIEW_SESSION_ID,
|
||||
): SessionReviewResponse {
|
||||
return {
|
||||
session_id: sessionId,
|
||||
learnerFeedbackEnabled: true,
|
||||
client: {
|
||||
name: "서연",
|
||||
initial: "서",
|
||||
|
|
@ -54,6 +65,7 @@ export function filledReviewResponse(
|
|||
turns: [
|
||||
{
|
||||
id: "t1",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[0],
|
||||
ts: "03:12",
|
||||
speaker: "client",
|
||||
who: "서연",
|
||||
|
|
@ -64,6 +76,7 @@ export function filledReviewResponse(
|
|||
},
|
||||
{
|
||||
id: "t2",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[1],
|
||||
ts: "05:04",
|
||||
speaker: "learner",
|
||||
who: "나",
|
||||
|
|
@ -86,6 +99,7 @@ export function filledReviewResponse(
|
|||
},
|
||||
{
|
||||
id: "t3",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[2],
|
||||
ts: "12:41",
|
||||
speaker: "client",
|
||||
who: "서연",
|
||||
|
|
@ -96,6 +110,7 @@ export function filledReviewResponse(
|
|||
},
|
||||
{
|
||||
id: "t4",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[3],
|
||||
ts: "16:05",
|
||||
speaker: "learner",
|
||||
who: "나",
|
||||
|
|
@ -115,6 +130,7 @@ export function filledReviewResponse(
|
|||
},
|
||||
{
|
||||
id: "t5",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[4],
|
||||
ts: "24:18",
|
||||
speaker: "learner",
|
||||
who: "나",
|
||||
|
|
@ -134,6 +150,7 @@ export function filledReviewResponse(
|
|||
},
|
||||
{
|
||||
id: "t6",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[5],
|
||||
ts: "29:52",
|
||||
speaker: "client",
|
||||
who: "서연",
|
||||
|
|
@ -224,6 +241,41 @@ export function filledReviewResponse(
|
|||
"위험도 평가는 별도 안전평가 결과와 함께 확인해야 합니다.",
|
||||
],
|
||||
},
|
||||
firstSessionChecklist: {
|
||||
version: "first-session-rapport-open-question.v1",
|
||||
applicable: true,
|
||||
status: "ready",
|
||||
title: "첫 회기 라포·개방질문 체크리스트",
|
||||
note: "축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다.",
|
||||
criteria: [
|
||||
{
|
||||
criterionId: "first-session.rapport-reflection",
|
||||
label: "라포 형성 반영",
|
||||
description: "내담자의 감정이나 경험을 평가 없이 되짚는 발화를 확인합니다.",
|
||||
status: "met",
|
||||
evidenceTurns: [
|
||||
{
|
||||
turnId: "t2",
|
||||
turnSeq: 2,
|
||||
quote: "혼자서 버티고 있었다는 느낌이 들어요.",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
criterionId: "first-session.open-question-one-focus",
|
||||
label: "한 초점 개방질문",
|
||||
description: "한 번에 한 초점을 묻는 개방형 질문 발화를 확인합니다.",
|
||||
status: "met",
|
||||
evidenceTurns: [
|
||||
{
|
||||
turnId: "t2",
|
||||
turnSeq: 2,
|
||||
quote: "비교가 반복될수록 스스로를 의심하게 됐던 걸까요?",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
nextLine:
|
||||
"그 말을 들은 바로 그 순간, 몸에서는 어떤 반응이 먼저 올라왔나요?",
|
||||
clientFeedback:
|
||||
|
|
|
|||
|
|
@ -603,6 +603,34 @@ test.describe("session review", () => {
|
|||
await expect(page.getByText("방어를 낮춘 감정 반영")).toBeVisible();
|
||||
await expect(page.getByText("개입 전 준비도 확인")).toBeVisible();
|
||||
await expect(page.locator(".sr-chart__svg")).toBeVisible();
|
||||
const firstSessionChecklist = page.locator(
|
||||
".sr-card--first-session-checklist",
|
||||
);
|
||||
await expect(firstSessionChecklist).toContainText(
|
||||
"첫 회기 라포·개방질문 체크리스트",
|
||||
);
|
||||
await expect(firstSessionChecklist).toContainText(
|
||||
"first-session-rapport-open-question.v1",
|
||||
);
|
||||
await expect(firstSessionChecklist).toContainText("라포 형성 반영");
|
||||
await expect(firstSessionChecklist).toContainText("한 초점 개방질문");
|
||||
await expect(firstSessionChecklist.getByText("근거 확인")).toHaveCount(2);
|
||||
await expect(firstSessionChecklist).toContainText(
|
||||
"임상 평가나 성적 판정이 아닙니다.",
|
||||
);
|
||||
const checklistEvidence = firstSessionChecklist.getByRole("button", {
|
||||
name: "라포 형성 반영 근거 2번 발화로 이동",
|
||||
});
|
||||
await expect(checklistEvidence).toBeVisible();
|
||||
const checklistEvidenceHeight = await checklistEvidence.evaluate((button) =>
|
||||
Math.ceil(button.getBoundingClientRect().height),
|
||||
);
|
||||
expect(checklistEvidenceHeight).toBeGreaterThanOrEqual(44);
|
||||
await checklistEvidence.click();
|
||||
await expect(page.locator("#sr-panel-transcript")).toBeVisible();
|
||||
await expect(page.locator(".sr-turn--active")).toContainText(
|
||||
"혼자서 버티고 있었다는 느낌",
|
||||
);
|
||||
await openReviewTab(page, "워크시트");
|
||||
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
|
||||
await expect(page.locator(".sr-ws-input").first()).toBeVisible();
|
||||
|
|
@ -612,31 +640,35 @@ test.describe("session review", () => {
|
|||
await expectNoHorizontalOverflow(page);
|
||||
});
|
||||
|
||||
test("keeps masked API transcript while naturalizing transcript placeholders", async ({
|
||||
test("renders role-safe counselor and client tokens without exposing mask syntax", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeLearnerReviewUser(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "review-placeholder-display";
|
||||
const sessionId = "00000000-0000-4000-8000-000000000112";
|
||||
const response = filledReviewResponse(sessionId);
|
||||
response.turns[0] = {
|
||||
...response.turns[0],
|
||||
text: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
|
||||
text: "저는 [CLIENT]이고 [ORG]에서 상담받고 있어요.",
|
||||
};
|
||||
response.turns[1] = {
|
||||
...response.turns[1],
|
||||
text: "[COUNSELOR]가 [CLIENT]의 말을 천천히 되짚었습니다.",
|
||||
};
|
||||
response.summary =
|
||||
"평가 AI는 [NAME]가 [ORG]에서 보인 조심스러운 반응을 분석했습니다.";
|
||||
"평가 AI는 [CLIENT]가 [ORG]에서 보인 반응과 [COUNSELOR]의 반영을 분석했습니다.";
|
||||
response.goodMoments[0] = {
|
||||
...response.goodMoments[0],
|
||||
title: "[NAME]가 보인 강점",
|
||||
body: "[ORG] 장면에서 내담자 [NAME]을 기다렸습니다.",
|
||||
title: "[COUNSELOR]가 보인 강점",
|
||||
body: "[ORG] 장면에서 [CLIENT] 반응을 기다렸습니다.",
|
||||
};
|
||||
response.growthPoints[0] = {
|
||||
...response.growthPoints[0],
|
||||
title: "[NAME] 반응을 서두르지 않기",
|
||||
body: "[ORG] 장면에서 내담자 [NAME]을 한 번 더 확인합니다.",
|
||||
body: "[ORG] 장면에서 익명 인물의 반응을 한 번 더 확인합니다.",
|
||||
};
|
||||
response.nextLine = "[NAME]의 말을 [ORG] 맥락에서 다시 확인할까요?";
|
||||
response.clientFeedback = "[NAME] 이야기를 [ORG]에서 안전하게 다뤘어요.";
|
||||
response.nextLine = "[CLIENT]의 말을 [ORG] 맥락에서 다시 확인할까요?";
|
||||
response.clientFeedback = "[CLIENT] 이야기를 [ORG]에서 안전하게 다뤘어요.";
|
||||
if (!response.caseWorksheet) {
|
||||
throw new Error("filled review fixture must include a worksheet");
|
||||
}
|
||||
|
|
@ -644,7 +676,7 @@ test.describe("session review", () => {
|
|||
{
|
||||
turnId: "t1",
|
||||
speaker: "client",
|
||||
quote: "저는 [NAME]이고 [ORG]에서 상담받고 있어요.",
|
||||
quote: "저는 [CLIENT]이고 [ORG]에서 상담받고 있어요.",
|
||||
},
|
||||
];
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
|
|
@ -660,37 +692,48 @@ test.describe("session review", () => {
|
|||
const reviewResponse = await reviewResponsePromise;
|
||||
await expectResponseOk(reviewResponse);
|
||||
const apiReview = await reviewResponse.json();
|
||||
expect(apiReview.turns[0].text).toContain("[NAME]");
|
||||
expect(apiReview.turns[0].text).toContain("[CLIENT]");
|
||||
expect(apiReview.turns[1].text).toContain("[COUNSELOR]");
|
||||
expect(apiReview.growthPoints[0].title).toContain("[NAME]");
|
||||
|
||||
const transcript = page.locator(".sr-card--transcript");
|
||||
await expect(transcript).toContainText(
|
||||
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
|
||||
"저는 내담자이고 소속 기관에서 상담받고 있어요.",
|
||||
);
|
||||
await expect(transcript).toContainText(
|
||||
"상담자가 내담자의 말을 천천히 되짚었습니다.",
|
||||
);
|
||||
await expect(transcript).not.toContainText("[NAME]");
|
||||
await expect(transcript).not.toContainText("[COUNSELOR]");
|
||||
await expect(transcript).not.toContainText("[CLIENT]");
|
||||
const summary = page.locator(".sr-summary");
|
||||
await expect(summary).toContainText(
|
||||
"평가 AI는 익명 내담자가 소속 기관에서 보인 조심스러운 반응을 분석했습니다.",
|
||||
"평가 AI는 내담자가 소속 기관에서 보인 반응과 상담자의 반영을 분석했습니다.",
|
||||
);
|
||||
await expect(summary).not.toContainText("[NAME]");
|
||||
await expect(summary).not.toContainText("[ORG]");
|
||||
await openReviewTab(page, "피드백");
|
||||
const feedback = page.locator("#sr-panel-feedback");
|
||||
await expect(feedback).toContainText("익명 내담자가 보인 강점");
|
||||
await expect(feedback).toContainText("상담자가 보인 강점");
|
||||
await expect(feedback).toContainText("소속 기관 장면에서 내담자 반응을 기다렸습니다.");
|
||||
await expect(feedback).toContainText("익명 인물 반응을 서두르지 않기");
|
||||
await expect(feedback).toContainText(
|
||||
"익명 내담자의 말을 소속 기관 맥락에서 다시 확인할까요?",
|
||||
"내담자의 말을 소속 기관 맥락에서 다시 확인할까요?",
|
||||
);
|
||||
await expect(feedback).not.toContainText("[NAME]");
|
||||
await expect(feedback).not.toContainText("[COUNSELOR]");
|
||||
await expect(feedback).not.toContainText("[CLIENT]");
|
||||
await expect(feedback).not.toContainText("[ORG]");
|
||||
await openReviewTab(page, "워크시트");
|
||||
const worksheetPanel = page.locator("#sr-panel-worksheet");
|
||||
const worksheetEvidence = worksheetPanel.locator(".sr-ws-evidence").filter({
|
||||
hasText: "저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
|
||||
hasText: "저는 내담자이고 소속 기관에서 상담받고 있어요.",
|
||||
});
|
||||
await expect(worksheetEvidence).toContainText(
|
||||
"저는 익명 내담자이고 소속 기관에서 상담받고 있어요.",
|
||||
"저는 내담자이고 소속 기관에서 상담받고 있어요.",
|
||||
);
|
||||
await expect(worksheetPanel).not.toContainText("[NAME]");
|
||||
await expect(worksheetPanel).not.toContainText("[CLIENT]");
|
||||
await expect(worksheetPanel).not.toContainText("[ORG]");
|
||||
});
|
||||
|
||||
|
|
@ -726,4 +769,288 @@ test.describe("session review", () => {
|
|||
await expect(prepostCard.getByRole("alert")).toContainText("기존 값을 비우려면 새 점수를 입력해 주세요.");
|
||||
await expect(prepostCard.getByRole("button", { name: "점수 저장" })).toBeDisabled();
|
||||
});
|
||||
|
||||
test("keeps learner inputs and privacy controls while disabled AI feedback makes no derived requests", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeLearnerReviewUser(page);
|
||||
await routePrepostMeasures(page);
|
||||
const sessionId = "feedback-disabled-review";
|
||||
const base = filledReviewResponse(sessionId);
|
||||
const derivedRequests: string[] = [];
|
||||
const derivedRequestCounts = {
|
||||
rupture: 0,
|
||||
outcomeTrajectory: 0,
|
||||
deliberatePractice: 0,
|
||||
calibrationSupervisor: 0,
|
||||
multimodalDerived: 0,
|
||||
sessionEvaluation: 0,
|
||||
liveCoach: 0,
|
||||
share: 0,
|
||||
};
|
||||
let calibrationInputReads = 0;
|
||||
let multimodalPrivacyReads = 0;
|
||||
|
||||
page.on("request", (request) => {
|
||||
const url = new URL(request.url());
|
||||
const path = url.pathname;
|
||||
if (path.includes(`/sessions/${sessionId}/ruptures`)) {
|
||||
derivedRequestCounts.rupture += 1;
|
||||
}
|
||||
if (path.includes(`/sessions/${sessionId}/outcome-trajectory`)) {
|
||||
derivedRequestCounts.outcomeTrajectory += 1;
|
||||
}
|
||||
if (path.endsWith("/practice/learners/me") || path.includes("/practice/attempts/")) {
|
||||
derivedRequestCounts.deliberatePractice += 1;
|
||||
}
|
||||
if (
|
||||
path.includes("/internal/") &&
|
||||
(path.includes("/calibration/") || path.includes("/transfer-suites")) ||
|
||||
path.endsWith("/calibration/reviews") ||
|
||||
/\/calibration\/learners\/(?!me$)/.test(path)
|
||||
) {
|
||||
derivedRequestCounts.calibrationSupervisor += 1;
|
||||
}
|
||||
if (
|
||||
url.pathname.endsWith(`/sessions/${sessionId}/multimodal-alliance`) &&
|
||||
url.searchParams.get("include_derived") !== "false"
|
||||
) {
|
||||
derivedRequestCounts.multimodalDerived += 1;
|
||||
}
|
||||
if (path.includes(`/eval/sessions/${sessionId}`)) {
|
||||
derivedRequestCounts.sessionEvaluation += 1;
|
||||
}
|
||||
if (path.includes(`/sessions/${sessionId}/live-coach`)) {
|
||||
derivedRequestCounts.liveCoach += 1;
|
||||
}
|
||||
if (path.endsWith(`/sessions/${sessionId}/share`)) {
|
||||
derivedRequestCounts.share += 1;
|
||||
}
|
||||
if (Object.values(derivedRequestCounts).some((count) => count > 0)) {
|
||||
derivedRequests.push(`${request.method()} ${url.pathname}${url.search}`);
|
||||
}
|
||||
});
|
||||
|
||||
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
...base,
|
||||
sessionNo: 2,
|
||||
learnerFeedbackEnabled: false,
|
||||
reviewReady: false,
|
||||
degraded: false,
|
||||
supervisorState: "피드백 비활성",
|
||||
summary:
|
||||
"관리자가 이 계정의 AI 학습 피드백을 비활성화했습니다. 회기 축어록과 학습자 입력은 그대로 보존됩니다.",
|
||||
caseWorksheet: {
|
||||
...base.caseWorksheet,
|
||||
status: "saved_by_learner",
|
||||
generatedBy: "learner",
|
||||
savedAt: "2026-08-27T01:02:03Z",
|
||||
sections: [
|
||||
{
|
||||
key: "learner-notes",
|
||||
title: "내 기록",
|
||||
items: [
|
||||
{
|
||||
key: "presenting-complaint",
|
||||
label: "주호소",
|
||||
value: "학습자가 직접 저장한 원문",
|
||||
evidence: [],
|
||||
confidence: "none",
|
||||
emptyReason: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
limitations: ["학습자 작성"],
|
||||
},
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/calibration/learners/me**", async (route) => {
|
||||
calibrationInputReads += 1;
|
||||
const url = new URL(route.request().url());
|
||||
expect(url.searchParams.get("session_id")).toBe(sessionId);
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
learner_id: "00000000-0000-0000-0000-000000000101",
|
||||
requested_view: "learner",
|
||||
clinical_claim_allowed: false,
|
||||
prediction_histories: [],
|
||||
calibration_assessments: [],
|
||||
transfer_suites: [],
|
||||
teacher_reviews: [],
|
||||
actual_executions: [],
|
||||
actual_transfer_assessments: [],
|
||||
}),
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
`**/api/sessions/${sessionId}/multimodal-alliance**`,
|
||||
async (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
if (url.pathname.includes("/raw-audio/")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "audio/webm",
|
||||
body: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (url.pathname.endsWith("/raw-audio")) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
audio_asset_id: "00000000-0000-4000-8000-000000000901",
|
||||
session_id: sessionId,
|
||||
media_type: "audio/webm",
|
||||
byte_size: 8192,
|
||||
duration_ms: 12000,
|
||||
retained_until: "2026-09-26T01:02:03Z",
|
||||
created_at: "2026-08-27T01:02:03Z",
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
multimodalPrivacyReads += 1;
|
||||
expect(url.searchParams.get("include_derived")).toBe("false");
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
learner_id: "00000000-0000-0000-0000-000000000101",
|
||||
clinical_claim_allowed: false,
|
||||
consent_snapshots: [
|
||||
{
|
||||
consent_snapshot_id: "00000000-0000-4000-8000-000000000902",
|
||||
sequence_no: 1,
|
||||
consent_status: "granted",
|
||||
retain_audio: true,
|
||||
retain_derived_features: false,
|
||||
transcript_retained: true,
|
||||
retention_days: 30,
|
||||
policy_version: "vignette.multimodal-consent.v1",
|
||||
reason_code: null,
|
||||
created_at: "2026-08-27T01:02:03Z",
|
||||
},
|
||||
],
|
||||
timelines: [],
|
||||
word_timestamps: [],
|
||||
voice_events: [],
|
||||
measurements: [],
|
||||
fusion_decisions: [],
|
||||
deletion_requests: [],
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
items: [
|
||||
{
|
||||
pulse_id: "00000000-0000-4000-8000-000000000903",
|
||||
checkpoint: "post",
|
||||
status: "ready",
|
||||
learner_locked_at: "2026-08-27T01:02:03Z",
|
||||
revealed_at: "2026-08-27T01:03:03Z",
|
||||
error_code: null,
|
||||
self_scores: { goal: 0.8, task: 0.6, bond: 1.0 },
|
||||
measurements: [
|
||||
{
|
||||
perspective: "independent_observer",
|
||||
rationale: "학습자에게 노출되면 안 되는 AI 비밀 근거",
|
||||
evidence: [{ text: "AI 비밀 근거" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`/learn/session/${sessionId}/review`);
|
||||
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("button", { name: "공유 URL 복사" })).toHaveCount(0);
|
||||
await expect(page.locator(".ap-card")).toContainText("저장한 내 치료 동맹 자기평가");
|
||||
await expect(page.locator(".ap-card")).toContainText("목표80점");
|
||||
await expect(page.locator(".ap-comparison")).toHaveCount(0);
|
||||
await expect(page.getByText("AI 비밀 근거", { exact: false })).toHaveCount(0);
|
||||
|
||||
await openReviewTab(page, "피드백");
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "내가 관찰한 현재 상태를 직접 기록합니다",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "먼저 예측하고 잠그는 내 기록만 보존합니다",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole("heading", {
|
||||
name: "동의·보존·삭제 범위를 직접 관리합니다",
|
||||
}),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText(/학습자 본인 계정에서만 접근 가능/)).toBeVisible();
|
||||
await expect(page.locator(".rr-card, .dp-card")).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(
|
||||
".ot-timeline-wrap, .ct-calibration, .ct-transfer, .mma-clock, .mma-axis-board",
|
||||
),
|
||||
).toHaveCount(0);
|
||||
await expect(
|
||||
page.locator(
|
||||
".sr-card--chart, .sr-card--flow, .sr-card--rubric, .sr-card--first-session-checklist, .sr-feedback",
|
||||
),
|
||||
).toHaveCount(0);
|
||||
|
||||
const privacyDelete = page.getByRole("button", {
|
||||
name: "선택 데이터 삭제 요청",
|
||||
});
|
||||
await privacyDelete.scrollIntoViewIfNeeded();
|
||||
const privacyDeleteHeight = await privacyDelete.evaluate((button) =>
|
||||
Math.ceil(button.getBoundingClientRect().height),
|
||||
);
|
||||
expect(privacyDeleteHeight).toBeGreaterThanOrEqual(44);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
await openReviewTab(page, "워크시트");
|
||||
await expect(page.locator(".sr-ws-input").first()).toHaveValue(
|
||||
"학습자가 직접 저장한 원문",
|
||||
);
|
||||
await openReviewTab(page, "축어록");
|
||||
await expect(page.locator(".sr-card--transcript")).toContainText(
|
||||
"다른 친구들이랑 비교할 때마다",
|
||||
);
|
||||
await expectNoHorizontalOverflow(page);
|
||||
|
||||
expect(calibrationInputReads).toBeGreaterThanOrEqual(1);
|
||||
expect(multimodalPrivacyReads).toBeGreaterThanOrEqual(1);
|
||||
expect(derivedRequestCounts).toEqual({
|
||||
rupture: 0,
|
||||
outcomeTrajectory: 0,
|
||||
deliberatePractice: 0,
|
||||
calibrationSupervisor: 0,
|
||||
multimodalDerived: 0,
|
||||
sessionEvaluation: 0,
|
||||
liveCoach: 0,
|
||||
share: 0,
|
||||
});
|
||||
expect(derivedRequests).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,10 +28,9 @@ function uniqueE2EEmail(prefix: string, domain: string) {
|
|||
return `${prefix}.${Date.now()}.${e2eEmailCounter}@${domain}`;
|
||||
}
|
||||
|
||||
export async function withGlobalEngineConfigLock<T>(
|
||||
export async function acquireGlobalEngineConfigLock(
|
||||
label: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
): Promise<() => Promise<void>> {
|
||||
const dir = path.join(process.cwd(), "node_modules", ".tmp");
|
||||
const lockPath = path.join(dir, "engine-config.lock");
|
||||
const startedAt = Date.now();
|
||||
|
|
@ -43,11 +42,18 @@ export async function withGlobalEngineConfigLock<T>(
|
|||
const handle = await fs.open(lockPath, "wx");
|
||||
try {
|
||||
await handle.writeFile(`${process.pid} ${label} ${new Date().toISOString()}`);
|
||||
return await action();
|
||||
} finally {
|
||||
} catch (err) {
|
||||
await handle.close().catch(() => undefined);
|
||||
await fs.unlink(lockPath).catch(() => undefined);
|
||||
throw err;
|
||||
}
|
||||
let released = false;
|
||||
return async () => {
|
||||
if (released) return;
|
||||
released = true;
|
||||
await handle.close().catch(() => undefined);
|
||||
await fs.unlink(lockPath).catch(() => undefined);
|
||||
};
|
||||
} catch (err) {
|
||||
const code =
|
||||
typeof err === "object" && err !== null && "code" in err
|
||||
|
|
@ -68,6 +74,18 @@ export async function withGlobalEngineConfigLock<T>(
|
|||
}
|
||||
}
|
||||
|
||||
export async function withGlobalEngineConfigLock<T>(
|
||||
label: string,
|
||||
action: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const release = await acquireGlobalEngineConfigLock(label);
|
||||
try {
|
||||
return await action();
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
|
||||
const E2E_COHORT_ID = "e2e-hanshin";
|
||||
|
||||
export async function signInAsLearner(page: Page) {
|
||||
|
|
|
|||
|
|
@ -88,7 +88,7 @@ async function routeLoginScreen(page: Page) {
|
|||
await page.route("**/auth/config", (route) =>
|
||||
route.fulfill(
|
||||
jsonRoute({
|
||||
allowed_email_domains: ["hs.ac.kr"],
|
||||
allowed_email_domains: [],
|
||||
dev_login_enabled: false,
|
||||
google_oauth_configured: true,
|
||||
providers: [
|
||||
|
|
@ -905,7 +905,7 @@ async function openLogin(page: Page) {
|
|||
await routeLoginScreen(page);
|
||||
await page.goto("/login");
|
||||
await expect(page.locator(".lg-root, .lg-enter").first()).toBeVisible({ timeout: 15_000 });
|
||||
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
|
||||
await expect(page.getByRole("button", { name: /^Google 계정으로 계속/ })).toBeVisible();
|
||||
}
|
||||
|
||||
async function openLearnerHome(page: Page) {
|
||||
|
|
@ -974,12 +974,12 @@ test.describe("uc a11y-mobile — 주요 6화면 접근성·모바일 감사", (
|
|||
});
|
||||
|
||||
// usecase: 저시력 사용자가 주 로그인 버튼의 문구를 읽는다
|
||||
test("로그인 주 CTA(학교 Google 계정으로 계속)의 색 대비가 4.5:1 이상이다", async ({ page }) => {
|
||||
test("로그인 주 CTA(Google 계정으로 계속)의 색 대비가 4.5:1 이상이다", async ({ page }) => {
|
||||
await openLogin(page);
|
||||
await expectCtaContrast(
|
||||
page.getByRole("button", { name: /학교 Google 계정으로 계속/ }),
|
||||
page.getByRole("button", { name: /^Google 계정으로 계속/ }),
|
||||
"login",
|
||||
"학교 Google 계정으로 계속",
|
||||
"Google 계정으로 계속",
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -39,6 +39,7 @@ interface FixtureUser {
|
|||
display_name: string;
|
||||
role: "learner" | "teacher" | "admin";
|
||||
admin_access: boolean;
|
||||
learner_feedback_enabled: boolean;
|
||||
super_admin: boolean;
|
||||
account_status: "pending" | "approved" | "suspended";
|
||||
affiliation: string;
|
||||
|
|
@ -55,6 +56,7 @@ function makeUser(overrides: Partial<FixtureUser> & { user_id: string }): Fixtur
|
|||
display_name: overrides.user_id,
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
learner_feedback_enabled: true,
|
||||
super_admin: false,
|
||||
account_status: "approved",
|
||||
affiliation: "",
|
||||
|
|
@ -90,6 +92,43 @@ interface FixtureTicket {
|
|||
updated_at: number;
|
||||
}
|
||||
|
||||
interface FixtureProtocol {
|
||||
protocol_id: string;
|
||||
source_id: string;
|
||||
title: string;
|
||||
source: string;
|
||||
version: number;
|
||||
license: "A" | "B" | "C" | "D";
|
||||
external_llm_ok: boolean;
|
||||
content: string;
|
||||
content_hash: string;
|
||||
status: "draft" | "active" | "retired";
|
||||
registered_by: string;
|
||||
registered_at: string;
|
||||
activated_at: string | null;
|
||||
retired_at: string | null;
|
||||
}
|
||||
|
||||
function makeProtocol(
|
||||
overrides: Partial<FixtureProtocol> & { protocol_id: string; title: string },
|
||||
): FixtureProtocol {
|
||||
return {
|
||||
source_id: `protocol:${overrides.protocol_id}`,
|
||||
source: "https://example.edu/protocols/default",
|
||||
version: 1,
|
||||
license: "B",
|
||||
external_llm_ok: false,
|
||||
content: "위험도를 먼저 확인한다.",
|
||||
content_hash: "a".repeat(64),
|
||||
status: "draft",
|
||||
registered_by: "uc-admin-1",
|
||||
registered_at: CREATED_AT,
|
||||
activated_at: null,
|
||||
retired_at: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function makeTicket(
|
||||
overrides: Partial<FixtureTicket> & { ticket_id: string; subject: string },
|
||||
): FixtureTicket {
|
||||
|
|
@ -140,6 +179,8 @@ function ticketsSummary(tickets: FixtureTicket[]) {
|
|||
interface AdminMockOptions {
|
||||
users?: FixtureUser[];
|
||||
tickets?: FixtureTicket[];
|
||||
protocols?: FixtureProtocol[];
|
||||
protocolActivationFailures?: number;
|
||||
services?: Array<{
|
||||
key: string;
|
||||
name: string;
|
||||
|
|
@ -164,6 +205,10 @@ interface AdminMockOptions {
|
|||
async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
|
||||
const users = options.users ?? [];
|
||||
let tickets = options.tickets ?? [];
|
||||
let protocols = options.protocols ?? [];
|
||||
let protocolActivationFailures = options.protocolActivationFailures ?? 0;
|
||||
const createdUserBodies: Array<Record<string, unknown>> = [];
|
||||
const createdProtocolBodies: Array<Record<string, unknown>> = [];
|
||||
const patchedUserBodies: Array<Record<string, unknown>> = [];
|
||||
const patchedTicketBodies: Array<Record<string, unknown>> = [];
|
||||
|
||||
|
|
@ -218,6 +263,101 @@ async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
|
|||
return;
|
||||
}
|
||||
|
||||
if (method === "GET" && path.endsWith("/admin/protocols")) {
|
||||
await fulfill({ protocols, total: protocols.length });
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && path.endsWith("/admin/protocols")) {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
createdProtocolBodies.push(body);
|
||||
const protocolId = `71000000-0000-4000-8000-${String(
|
||||
createdProtocolBodies.length,
|
||||
).padStart(12, "0")}`;
|
||||
const created = makeProtocol({
|
||||
protocol_id: protocolId,
|
||||
title: String(body.title ?? ""),
|
||||
source: String(body.source ?? ""),
|
||||
version: Number(body.version ?? 1),
|
||||
license: (body.license as FixtureProtocol["license"] | undefined) ?? "B",
|
||||
external_llm_ok: body.external_llm_ok === true,
|
||||
content: String(body.content ?? ""),
|
||||
content_hash: "b".repeat(64),
|
||||
});
|
||||
protocols = [created, ...protocols];
|
||||
await fulfill(created, 201);
|
||||
return;
|
||||
}
|
||||
|
||||
const protocolAction = /\/admin\/protocols\/([^/]+)\/(activate|retire)$/.exec(path);
|
||||
if (method === "POST" && protocolAction) {
|
||||
const [, protocolId, action] = protocolAction;
|
||||
const index = protocols.findIndex((item) => item.protocol_id === protocolId);
|
||||
if (index === -1) {
|
||||
await fulfill({ detail: "unknown protocol" }, 404);
|
||||
return;
|
||||
}
|
||||
if (action === "activate") {
|
||||
if (protocolActivationFailures > 0) {
|
||||
protocolActivationFailures -= 1;
|
||||
await fulfill({ detail: "vector unavailable" }, 503);
|
||||
return;
|
||||
}
|
||||
if (protocols[index].status !== "draft") {
|
||||
await fulfill({ detail: "draft required" }, 409);
|
||||
return;
|
||||
}
|
||||
protocols[index] = {
|
||||
...protocols[index],
|
||||
status: "active",
|
||||
activated_at: CREATED_AT,
|
||||
};
|
||||
await fulfill({
|
||||
protocol: protocols[index],
|
||||
chunks_indexed: 2,
|
||||
skipped_unchanged: false,
|
||||
embedded: true,
|
||||
degraded: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (protocols[index].status !== "active") {
|
||||
await fulfill({ detail: "active required" }, 409);
|
||||
return;
|
||||
}
|
||||
protocols[index] = {
|
||||
...protocols[index],
|
||||
status: "retired",
|
||||
retired_at: CREATED_AT,
|
||||
};
|
||||
await fulfill(protocols[index]);
|
||||
return;
|
||||
}
|
||||
|
||||
if (method === "POST" && path.endsWith("/admin/users")) {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
createdUserBodies.push(body);
|
||||
const created = makeUser({
|
||||
user_id: `preregistered-${createdUserBodies.length}`,
|
||||
email: String(body.email ?? ""),
|
||||
display_name: String(body.display_name ?? ""),
|
||||
role: (body.role as FixtureUser["role"] | undefined) ?? "learner",
|
||||
admin_access: Boolean(body.admin_access),
|
||||
learner_feedback_enabled: body.learner_feedback_enabled !== false,
|
||||
account_status:
|
||||
(body.account_status as FixtureUser["account_status"] | undefined) ?? "pending",
|
||||
affiliation: String(body.affiliation ?? ""),
|
||||
cohort_ids: Array.isArray(body.cohort_ids)
|
||||
? body.cohort_ids.map((value) => String(value))
|
||||
: [],
|
||||
created_at: NOW,
|
||||
last_seen_at: NOW,
|
||||
});
|
||||
users.unshift(created);
|
||||
await fulfill(created, 201);
|
||||
return;
|
||||
}
|
||||
|
||||
const userPatch = /\/admin\/users\/([^/]+)$/.exec(path);
|
||||
if (method === "PATCH" && userPatch) {
|
||||
const body = request.postDataJSON() as Record<string, unknown>;
|
||||
|
|
@ -369,7 +509,12 @@ async function installAdminMock(page: Page, options: AdminMockOptions = {}) {
|
|||
await fulfill({ detail: `unmocked API request: ${method} ${path}` }, 404);
|
||||
});
|
||||
|
||||
return { patchedUserBodies, patchedTicketBodies };
|
||||
return {
|
||||
createdUserBodies,
|
||||
createdProtocolBodies,
|
||||
patchedUserBodies,
|
||||
patchedTicketBodies,
|
||||
};
|
||||
}
|
||||
|
||||
/** G8 지속 개선 콕핏 fixture — 모델 게이트는 증거 2종만, 릴리스 게이트는 4종 완비. */
|
||||
|
|
@ -1041,4 +1186,260 @@ test.describe("관리자 콘솔 유스케이스 여정", () => {
|
|||
await page.getByLabel("사용자 검색").fill("");
|
||||
await expect(rows).toHaveCount(3);
|
||||
});
|
||||
|
||||
// usecase: 관리자가 외부 연구참여자를 exact-email로 사전등록하고 별도로 승인한다
|
||||
test("외부 연구참여자 사전등록은 pending으로 생성되고 승인 큐를 거친다", async ({
|
||||
page,
|
||||
}) => {
|
||||
const { createdUserBodies, patchedUserBodies } = await installAdminMock(page, {
|
||||
users: [],
|
||||
});
|
||||
|
||||
await page.goto("/admin/users");
|
||||
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { name: "외부 연구참여자 사전등록" }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText("정확한 이메일 주소로만 Google·개발 로그인을 통과"))
|
||||
.toBeVisible();
|
||||
const fixedApprovalStatus = page.getByLabel("새 사용자 승인 상태");
|
||||
await expect(fixedApprovalStatus).toHaveAttribute("data-value", "pending");
|
||||
await expect(fixedApprovalStatus).toHaveText("승인 대기");
|
||||
await expect(
|
||||
page.getByRole("combobox", { name: "새 사용자 승인 상태" }),
|
||||
).toHaveCount(0);
|
||||
|
||||
const exactEmail = "participant.exact@gmail.com";
|
||||
await page.getByLabel("새 사용자 이메일").fill(exactEmail);
|
||||
await page.getByLabel("새 사용자 표시 이름").fill("외부 연구참여자");
|
||||
await page.getByLabel("새 사용자 코호트").fill("external-study-2026");
|
||||
|
||||
const createRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" && new URL(request.url()).pathname.endsWith("/admin/users"),
|
||||
);
|
||||
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
|
||||
const request = await createRequest;
|
||||
|
||||
expect(request.postDataJSON()).toMatchObject({
|
||||
email: exactEmail,
|
||||
display_name: "외부 연구참여자",
|
||||
role: "learner",
|
||||
account_status: "pending",
|
||||
cohort_ids: ["external-study-2026"],
|
||||
});
|
||||
expect(createdUserBodies).toHaveLength(1);
|
||||
|
||||
await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
await page.getByRole("tab", { name: "외부 연구참여자 사전등록" }).click();
|
||||
await expect(page.getByLabel("새 사용자 승인 상태")).toHaveAttribute(
|
||||
"data-value",
|
||||
"pending",
|
||||
);
|
||||
|
||||
const secondEmail = "participant.second@outlook.com";
|
||||
await page.getByLabel("새 사용자 이메일").fill(secondEmail);
|
||||
await page.getByLabel("새 사용자 표시 이름").fill("두 번째 연구참여자");
|
||||
const secondCreateRequest = page.waitForRequest(
|
||||
(nextRequest) =>
|
||||
nextRequest.method() === "POST" &&
|
||||
new URL(nextRequest.url()).pathname.endsWith("/admin/users"),
|
||||
);
|
||||
await page.getByRole("button", { name: "연구참여자 사전등록" }).click();
|
||||
const secondRequest = await secondCreateRequest;
|
||||
|
||||
expect(secondRequest.postDataJSON()).toMatchObject({
|
||||
email: secondEmail,
|
||||
display_name: "두 번째 연구참여자",
|
||||
role: "learner",
|
||||
account_status: "pending",
|
||||
});
|
||||
expect(createdUserBodies).toHaveLength(2);
|
||||
expect(createdUserBodies.every((body) => body.account_status === "pending")).toBe(true);
|
||||
|
||||
await page.getByRole("tab", { name: "가입 승인 2" }).click();
|
||||
const card = page.locator(".vgops-approval").filter({ hasText: exactEmail });
|
||||
await expect(card).toContainText("승인 대기");
|
||||
await card.getByRole("button", { name: "승인" }).click();
|
||||
const secondCard = page.locator(".vgops-approval").filter({ hasText: secondEmail });
|
||||
await expect(secondCard).toContainText("승인 대기");
|
||||
await secondCard.getByRole("button", { name: "승인" }).click();
|
||||
|
||||
expect(patchedUserBodies).toContainEqual({
|
||||
user_id: "preregistered-1",
|
||||
account_status: "approved",
|
||||
});
|
||||
expect(patchedUserBodies).toContainEqual({
|
||||
user_id: "preregistered-2",
|
||||
account_status: "approved",
|
||||
});
|
||||
await expect(page.getByText("처리할 가입 요청이 없습니다")).toBeVisible();
|
||||
});
|
||||
|
||||
test("사용자별 학습자 AI 피드백을 끄고 관리자 API에 저장한다", async ({ page }) => {
|
||||
const user = makeUser({
|
||||
user_id: "feedback-policy-user",
|
||||
email: "feedback-policy@hs.ac.kr",
|
||||
display_name: "피드백 정책 대상",
|
||||
});
|
||||
const { patchedUserBodies } = await installAdminMock(page, { users: [user] });
|
||||
|
||||
await page.goto("/admin/users");
|
||||
await page.getByRole("tab", { name: "사용자 목록" }).click();
|
||||
|
||||
const row = page
|
||||
.locator(".vgops-user-table tbody tr")
|
||||
.filter({ hasText: user.email });
|
||||
const tableScroll = page.locator(".vgops-user-table-scroll");
|
||||
await expect
|
||||
.poll(() =>
|
||||
tableScroll.evaluate(
|
||||
(element) => element.scrollWidth > element.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
const toggle = row.getByLabel(`${user.email} 학습자 AI 피드백`);
|
||||
await expect(toggle).toBeChecked();
|
||||
|
||||
await toggle.focus();
|
||||
await toggle.press("Space");
|
||||
await expect(toggle).not.toBeChecked();
|
||||
const patchRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "PATCH" &&
|
||||
request.url().includes(`/admin/users/${user.user_id}`),
|
||||
);
|
||||
await row.getByRole("button", { name: "저장" }).click();
|
||||
const request = await patchRequest;
|
||||
|
||||
expect(request.postDataJSON()).toMatchObject({ learner_feedback_enabled: false });
|
||||
expect(patchedUserBodies.at(-1)).toMatchObject({
|
||||
user_id: user.user_id,
|
||||
learner_feedback_enabled: false,
|
||||
});
|
||||
await expect(toggle).not.toBeChecked();
|
||||
});
|
||||
|
||||
test("상담 프로토콜을 초안 등록하고 활성화한 뒤 퇴역한다", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
const { createdProtocolBodies } = await installAdminMock(page, { protocols: [] });
|
||||
|
||||
await page.goto("/admin/access");
|
||||
const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" });
|
||||
await protocolTab.focus();
|
||||
await protocolTab.press("Enter");
|
||||
await expect(page.getByRole("heading", { name: "상담 프로토콜 초안 등록" }))
|
||||
.toBeVisible();
|
||||
|
||||
const title = "초기면담 위기 확인 프로토콜";
|
||||
await page.getByLabel("프로토콜 제목").fill(title);
|
||||
await page
|
||||
.getByLabel("프로토콜 출처")
|
||||
.fill("https://example.edu/protocols/intake-v2");
|
||||
await page.getByLabel("프로토콜 버전").fill("0");
|
||||
await page.getByLabel("프로토콜 라이선스").selectOption("B");
|
||||
await page.getByLabel("외부 LLM 사용 허용").check();
|
||||
await page
|
||||
.getByLabel("프로토콜 원문")
|
||||
.fill("자해 위험과 즉시 안전 여부를 먼저 확인한다.");
|
||||
await page.getByRole("button", { name: "초안 등록", exact: true }).click();
|
||||
await expect(page.getByRole("alert")).toContainText(
|
||||
"버전은 1부터 1,000,000 사이의 정수여야 합니다.",
|
||||
);
|
||||
expect(createdProtocolBodies).toHaveLength(0);
|
||||
await page.getByLabel("프로토콜 버전").fill("2");
|
||||
|
||||
const createRequest = page.waitForRequest(
|
||||
(request) =>
|
||||
request.method() === "POST" &&
|
||||
new URL(request.url()).pathname.endsWith("/admin/protocols"),
|
||||
);
|
||||
await page.getByRole("button", { name: "초안 등록", exact: true }).click();
|
||||
const request = await createRequest;
|
||||
expect(request.postDataJSON()).toMatchObject({
|
||||
title,
|
||||
version: 2,
|
||||
license: "B",
|
||||
external_llm_ok: true,
|
||||
});
|
||||
expect(createdProtocolBodies).toHaveLength(1);
|
||||
|
||||
const card = page.locator(".vgops-protocol-card").filter({ hasText: title });
|
||||
await expect(card).toContainText("초안");
|
||||
const activate = card.getByRole("button", { name: `${title} 활성화` });
|
||||
await activate.focus();
|
||||
await activate.press("Enter");
|
||||
await expect(card).toContainText("활성");
|
||||
const retire = card.getByRole("button", { name: `${title} 퇴역` });
|
||||
await retire.focus();
|
||||
await retire.press("Enter");
|
||||
await expect(card).toContainText("퇴역");
|
||||
await expect(card.getByText("변경 불가")).toBeVisible();
|
||||
});
|
||||
|
||||
test("모바일에서 C/D 외부 사용을 차단하고 활성화 오류를 복구한다", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 320, height: 568 });
|
||||
const protocol = makeProtocol({
|
||||
protocol_id: "71000000-0000-4000-8000-000000000099",
|
||||
title: "모바일 복구 프로토콜",
|
||||
license: "A",
|
||||
external_llm_ok: true,
|
||||
});
|
||||
await installAdminMock(page, {
|
||||
protocols: [protocol],
|
||||
protocolActivationFailures: 1,
|
||||
});
|
||||
|
||||
await page.goto("/admin/access");
|
||||
const protocolTab = page.getByRole("tab", { name: "상담 프로토콜" });
|
||||
await protocolTab.click();
|
||||
await expect(protocolTab).toHaveAttribute("aria-selected", "true");
|
||||
|
||||
await page.getByLabel("프로토콜 라이선스").selectOption("C");
|
||||
const externalToggle = page.getByLabel("외부 LLM 사용 허용");
|
||||
await expect(externalToggle).toBeDisabled();
|
||||
await expect(externalToggle).not.toBeChecked();
|
||||
await expect(page.getByText("라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."))
|
||||
.toBeVisible();
|
||||
|
||||
const card = page.locator(".vgops-protocol-card").filter({
|
||||
hasText: protocol.title,
|
||||
});
|
||||
const activate = card.getByRole("button", { name: `${protocol.title} 활성화` });
|
||||
await activate.click();
|
||||
await expect(page.getByRole("alert")).toContainText("vector unavailable");
|
||||
await activate.click();
|
||||
await expect(card).toContainText("활성");
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
|
||||
const protocolRegion = page.locator(".vgops-protocols");
|
||||
await expect
|
||||
.poll(() =>
|
||||
protocolRegion.evaluate(
|
||||
(element) => element.scrollWidth <= element.clientWidth,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => document.documentElement.scrollWidth <= window.innerWidth),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
const touchTargets = page.locator(
|
||||
".vgops-protocols button, .vgops-protocols input:not([type=checkbox]), " +
|
||||
".vgops-protocols select, .vgops-protocols textarea, .vgops-protocol-check",
|
||||
);
|
||||
const targetCount = await touchTargets.count();
|
||||
expect(targetCount).toBeGreaterThan(0);
|
||||
for (let index = 0; index < targetCount; index += 1) {
|
||||
const box = await touchTargets.nth(index).boundingBox();
|
||||
expect(box?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
}
|
||||
expect((await protocolTab.boundingBox())?.height ?? 0).toBeGreaterThanOrEqual(44);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@
|
|||
|
||||
근거:
|
||||
- src/pages/Login.tsx + login/LoginBrand.tsx + login/LoginPanel.tsx
|
||||
(허용 도메인 배지, 비치료 고지 문구, OAuth 준비 상태)
|
||||
(제한 없는 Google 계정 배지, 비치료 고지 문구, OAuth 준비 상태)
|
||||
- src/pages/Onboarding.tsx (canSubmit 필수 입력·동의 검증, isValidPhone,
|
||||
약관 details, source_note)
|
||||
- src/pages/PendingApproval.tsx (승인 상태 새로고침, 아직 승인 전 안내)
|
||||
|
|
@ -44,7 +44,7 @@ const READY_AUTH_CONFIG = {
|
|||
{ provider: "google", configured: true, enabled: true, login_path: "/auth/login?provider=google" },
|
||||
{ provider: "saml", configured: false, enabled: false, login_path: "/auth/login?provider=saml" },
|
||||
],
|
||||
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
|
||||
allowed_email_domains: [],
|
||||
redirect_uri: "http://127.0.0.1:8000/auth/callback",
|
||||
dev_login_enabled: false,
|
||||
};
|
||||
|
|
@ -156,8 +156,8 @@ async function fillOnboardingTextFields(page: Page, phone = "010-1234-5678") {
|
|||
}
|
||||
|
||||
test.describe("uc auth-onboarding", () => {
|
||||
// usecase: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·허용 도메인·비치료 고지를 읽는다
|
||||
test("로그인 화면에서 서비스 성격과 허용 도메인, 비치료 고지를 확인한다", async ({ page }) => {
|
||||
// usecase: 처음 방문한 사용자가 로그인 화면에서 서비스 소개·Google 계정 정책·비치료 고지를 읽는다
|
||||
test("로그인 화면에서 서비스 성격과 Google 계정 정책, 비치료 고지를 확인한다", async ({ page }) => {
|
||||
await routeUnmockedApi(page);
|
||||
await routeUnauthenticated(page);
|
||||
await routeAuthConfig(page);
|
||||
|
|
@ -165,13 +165,13 @@ test.describe("uc auth-onboarding", () => {
|
|||
await page.goto("/login");
|
||||
await expect(page.getByRole("heading", { name: "로그인" })).toBeVisible();
|
||||
await expect(page.locator(".lg-lead")).toContainText(
|
||||
"학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.",
|
||||
"이메일이 확인된 Google 계정이면 도메인이나 사전등록 없이 바로 시작할 수 있습니다.",
|
||||
);
|
||||
|
||||
// 허용 도메인 안내: 브랜드 패널 배지 + 버튼 서브라벨.
|
||||
await expect(page.locator(".lg-policy")).toContainText("허용 도메인");
|
||||
await expect(page.locator(".lg-policy b").first()).toHaveText("hs.ac.kr");
|
||||
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("@hs.ac.kr");
|
||||
// 제한 없는 Google 계정 안내: 브랜드 패널 배지 + 버튼 서브라벨.
|
||||
await expect(page.locator(".lg-policy")).toContainText("Google 로그인");
|
||||
await expect(page.locator(".lg-policy b")).toHaveText("모든 Google 계정");
|
||||
await expect(page.locator(".lg-obtn.primary .sub")).toHaveText("모든 Google 계정");
|
||||
|
||||
// 비치료 고지: 교육용 도구임을 명시한다.
|
||||
await expect(page.locator(".lg-note")).toContainText(
|
||||
|
|
|
|||
|
|
@ -233,7 +233,7 @@ const READY_AUTH_CONFIG = {
|
|||
login_path: "/auth/login?provider=google",
|
||||
},
|
||||
],
|
||||
allowed_email_domains: ["hs.ac.kr", "twentyoz.kr"],
|
||||
allowed_email_domains: [],
|
||||
redirect_uri: "http://127.0.0.1:8000/auth/callback",
|
||||
dev_login_enabled: false,
|
||||
};
|
||||
|
|
@ -500,7 +500,7 @@ test.describe("uc error-resilience-shell", () => {
|
|||
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
await expect(
|
||||
page.getByRole("button", { name: /학교 Google 계정으로 계속/ }),
|
||||
page.getByRole("button", { name: /Google 계정으로 계속/ }),
|
||||
).toBeVisible();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -130,6 +130,14 @@ const S_END = {
|
|||
|
||||
const BASE_SESSIONS = [S_ACT, S_END];
|
||||
|
||||
const TRAINING_EXPOSURE_CONTRACT = {
|
||||
version: "training-exposure-dominant-share.v1",
|
||||
minimum_completed_sessions: 4,
|
||||
attention_threshold: 0.75,
|
||||
definition:
|
||||
"종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, 공정성 평가나 임상진단이 아닙니다.",
|
||||
};
|
||||
|
||||
// 평가가 아직 없는 학습자 — 리뷰 0건, 평가 0회, 라포 평균 null.
|
||||
const DASH_NO_EVAL = {
|
||||
message: "학습 현황 요약",
|
||||
|
|
@ -185,6 +193,16 @@ const DASH_NO_EVAL = {
|
|||
},
|
||||
],
|
||||
achievements: [],
|
||||
training_exposure: {
|
||||
...TRAINING_EXPOSURE_CONTRACT,
|
||||
status: "insufficient",
|
||||
label: "판정 근거 부족",
|
||||
completed_sessions: 1,
|
||||
dominant_persona_code: "P2",
|
||||
dominant_persona_name: "김도윤",
|
||||
dominant_sessions: 1,
|
||||
dominant_share: 1,
|
||||
},
|
||||
};
|
||||
|
||||
// 라포 게이지 검증용 — 서로 다른 rapport_percent(81/47)를 가진 진행 기록.
|
||||
|
|
@ -240,6 +258,16 @@ const EMPTY_DASHBOARD = {
|
|||
recent_feedback: [],
|
||||
persona_progress: [],
|
||||
achievements: [],
|
||||
training_exposure: {
|
||||
...TRAINING_EXPOSURE_CONTRACT,
|
||||
status: "insufficient",
|
||||
label: "판정 근거 부족",
|
||||
completed_sessions: 0,
|
||||
dominant_persona_code: null,
|
||||
dominant_persona_name: null,
|
||||
dominant_sessions: 0,
|
||||
dominant_share: null,
|
||||
},
|
||||
};
|
||||
|
||||
// 스포트라이트(진행 중 회기) 상세 — 리캡 fetch 오류 상태를 만들지 않기 위해 고정.
|
||||
|
|
@ -309,6 +337,36 @@ const DASH_BULK = {
|
|||
learner_turns: 150,
|
||||
client_turns: 150,
|
||||
},
|
||||
training_exposure: {
|
||||
...TRAINING_EXPOSURE_CONTRACT,
|
||||
status: "balanced",
|
||||
label: "균형",
|
||||
completed_sessions: 50,
|
||||
dominant_persona_code: "P1",
|
||||
dominant_persona_name: "성하늘",
|
||||
dominant_sessions: 25,
|
||||
dominant_share: 0.5,
|
||||
},
|
||||
};
|
||||
|
||||
const DASH_ATTENTION = {
|
||||
...DASH_NO_EVAL,
|
||||
overview: {
|
||||
...DASH_NO_EVAL.overview,
|
||||
total_sessions: 4,
|
||||
active_sessions: 0,
|
||||
completed_sessions: 4,
|
||||
},
|
||||
training_exposure: {
|
||||
...TRAINING_EXPOSURE_CONTRACT,
|
||||
status: "attention",
|
||||
label: "훈련 집중 주의",
|
||||
completed_sessions: 4,
|
||||
dominant_persona_code: "P1",
|
||||
dominant_persona_name: "성하늘",
|
||||
dominant_sessions: 3,
|
||||
dominant_share: 0.75,
|
||||
},
|
||||
};
|
||||
|
||||
interface FixtureOptions {
|
||||
|
|
@ -366,6 +424,68 @@ test.describe("uc learner home dashboard", () => {
|
|||
await expect(rapportCard).toContainText("성하늘 · 1회");
|
||||
});
|
||||
|
||||
test("종료 회기 0회는 훈련 노출 판정 근거 부족으로 표시한다", async ({ page }) => {
|
||||
await installFixtures(page, {
|
||||
sessions: [],
|
||||
dashboard: EMPTY_DASHBOARD,
|
||||
detail: null,
|
||||
});
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
|
||||
hasText: "훈련 노출 점검",
|
||||
});
|
||||
await expect(exposure).toContainText("판정 근거 부족");
|
||||
await expect(exposure).toContainText("종료 회기 0/4회");
|
||||
await expect(exposure).toContainText(
|
||||
"공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.",
|
||||
);
|
||||
});
|
||||
|
||||
test("종료 회기 1회는 비중과 무관하게 훈련 노출 판정을 보류한다", async ({ page }) => {
|
||||
await installFixtures(page);
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
|
||||
hasText: "훈련 노출 점검",
|
||||
});
|
||||
await expect(exposure).toContainText("판정 근거 부족");
|
||||
await expect(exposure).toContainText("종료 회기 1/4회");
|
||||
});
|
||||
|
||||
test("종료 회기 50회의 동률 노출은 균형으로 표시한다", async ({ page }) => {
|
||||
await installFixtures(page, {
|
||||
sessions: BULK_SESSIONS,
|
||||
dashboard: DASH_BULK,
|
||||
detail: null,
|
||||
});
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
|
||||
hasText: "훈련 노출 점검",
|
||||
});
|
||||
await expect(exposure).toContainText("균형");
|
||||
await expect(exposure).toContainText("성하늘 25/50회 · 최다 노출 50%");
|
||||
});
|
||||
|
||||
test("종료 회기 4회 중 3회 노출은 훈련 집중 주의로 표시한다", async ({ page }) => {
|
||||
await installFixtures(page, { dashboard: DASH_ATTENTION });
|
||||
await page.goto("/learn");
|
||||
await page.getByRole("tab", { name: "성장 지표" }).click();
|
||||
|
||||
const exposure = page.locator("#lh-panel-growth .lh-insight-list").filter({
|
||||
hasText: "훈련 노출 점검",
|
||||
});
|
||||
await expect(exposure).toContainText("훈련 집중 주의");
|
||||
await expect(exposure).toContainText("성하늘 3/4회 · 최다 노출 75%");
|
||||
await expect(exposure).toContainText(
|
||||
"공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.",
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 학습자가 '리뷰 대기' 메트릭 카드를 눌러 기록 화면으로 이동한다
|
||||
test("리뷰 대기 메트릭 카드를 누르면 기록 화면으로 이동한다", async ({ page }) => {
|
||||
await installFixtures(page);
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
import { expect, test, type Page, type Route } from "@playwright/test";
|
||||
import {
|
||||
FILLED_REVIEW_SESSION_ID,
|
||||
FILLED_REVIEW_TURN_IDS,
|
||||
routeFilledSessionReview,
|
||||
routePrepostMeasures,
|
||||
} from "./session-review-fixture";
|
||||
|
|
@ -297,7 +298,7 @@ function revealedMeasurements() {
|
|||
: "목표와 과업을 함께 확인한 발화를 근거로 판단했습니다.",
|
||||
evidence: [
|
||||
{
|
||||
turn_id: "t6",
|
||||
turn_id: FILLED_REVIEW_TURN_IDS[5],
|
||||
seq: 6,
|
||||
speaker: "client",
|
||||
text: "적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
|
||||
|
|
@ -504,6 +505,44 @@ test.describe("UC 치료 동맹 펄스 — 회기 중 자기점검(mid)", () =>
|
|||
// ── post: 리뷰에서 잠금·공개·수정 불가 ─────────────────────────────
|
||||
|
||||
test.describe("UC 치료 동맹 펄스 — 리뷰(post)", () => {
|
||||
test("근거 장면은 화면용 t번호가 아니라 영속 UUID로 잠근다", async ({ page }) => {
|
||||
await routeReviewPage(page);
|
||||
let submittedBody: Record<string, unknown> | null = null;
|
||||
await page.route(
|
||||
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
|
||||
async (route) => {
|
||||
if (route.request().method() === "POST") {
|
||||
submittedBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await fulfillJson(
|
||||
route,
|
||||
{ pulse_id: "pulse-post-uuid", status: "awaiting_agents" },
|
||||
202,
|
||||
);
|
||||
return;
|
||||
}
|
||||
await fulfillJson(route, { items: [] });
|
||||
},
|
||||
);
|
||||
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
|
||||
|
||||
await pickReviewScore(page, "목표 합의 평가", "4 대체로 그렇다");
|
||||
await pickReviewScore(page, "과업 합의 평가", "3 보통이다");
|
||||
await pickReviewScore(page, "정서적 유대 평가", "5 매우 그렇다");
|
||||
await page.getByText("내 판단의 근거 장면 선택").click();
|
||||
await page.getByRole("checkbox").first().check();
|
||||
await page.getByRole("button", { name: "내 판단 잠그고 관점 비교" }).click();
|
||||
|
||||
await expect.poll(() => submittedBody).not.toBeNull();
|
||||
expect(submittedBody).toEqual({
|
||||
checkpoint: "post",
|
||||
scores: { goal: 0.75, task: 0.5, bond: 1 },
|
||||
evidence_turn_ids: [FILLED_REVIEW_TURN_IDS[0]],
|
||||
});
|
||||
expect(FILLED_REVIEW_TURN_IDS[0]).toMatch(
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
|
||||
);
|
||||
});
|
||||
|
||||
// usecase: 리뷰에서 근거 장면 없이 세 축만 고른 학습자는 post 판단을 잠글 수 없다
|
||||
test("리뷰에서 근거 장면 없이 세 축만 고르면 post 판단을 잠글 수 없다", async ({
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -545,6 +545,42 @@ test.describe("회기 대화 — 텍스트 턴과 SSE 스트림", () => {
|
|||
expect(stream.calls).toBe(2);
|
||||
});
|
||||
|
||||
// usecase: HTTP 200 스트림이 오류 이벤트 없이 끊겨도 성공으로 오인하지 않고 같은 발화로 회복한다
|
||||
test("done 없이 스트림이 끝나면 실패로 표시하고 같은 발화 재시도를 보장한다", async ({
|
||||
page,
|
||||
}) => {
|
||||
await routeSessionScreen(page);
|
||||
const partialText = "연결이 끊기기 전 일부 응답";
|
||||
const stream = await installStream(page, async (index, route) => {
|
||||
if (index === 0) {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "text/event-stream",
|
||||
body: ["event: token", `data: ${partialText}`, ""].join("\n"),
|
||||
});
|
||||
return;
|
||||
}
|
||||
await fulfillTurn(route, REPLY_TOKENS, doneEvent(1));
|
||||
});
|
||||
await startFreshSession(page);
|
||||
|
||||
const composer = composerOf(page);
|
||||
await composer.fill(LEARNER_TEXT);
|
||||
await sendButtonOf(page).click();
|
||||
|
||||
const alert = page.getByRole("alert").filter({ hasText: "응답 연결이 중간에 끊겼습니다" });
|
||||
await expect(alert).toBeVisible();
|
||||
await expect(alert).toContainText("입력 내용은 복원했습니다");
|
||||
await expect(page.locator(".sx-utt")).toHaveCount(0);
|
||||
await expect(page.getByText(partialText)).toHaveCount(0);
|
||||
await expect(composer).toHaveValue(LEARNER_TEXT);
|
||||
|
||||
await sendButtonOf(page).click();
|
||||
await expect(clientUtterance(page).filter({ hasText: REPLY_FULL })).toBeVisible();
|
||||
await expect(alert).toHaveCount(0);
|
||||
expect(stream.calls).toBe(2);
|
||||
});
|
||||
|
||||
// usecase: 내담자가 응답을 내지 못한 턴은 빈 말풍선 없이 정직하게 끝나고 다음 발화를 이어갈 수 있다
|
||||
test("빈 내담자 응답 턴은 가짜 말풍선 없이 종료되고 대화를 이어갈 수 있다", async ({
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ const publicStorageState = process.env.E2E_PUBLIC_STORAGE_STATE;
|
|||
const includePublicAuth = process.env.E2E_PUBLIC_AUTH === "1";
|
||||
const shouldStartWebServer =
|
||||
!includePublicAuth && !process.env.PLAYWRIGHT_BASE_URL && !process.env.PLAYWRIGHT_SKIP_WEB_SERVER;
|
||||
const reuseExistingServer = process.env.PLAYWRIGHT_REUSE_EXISTING_SERVER === "1";
|
||||
const singleRunPattern = /@single-run/;
|
||||
const publicAuthPattern = /@public-auth/;
|
||||
const localProjectExclusions = /@single-run|@public-auth/;
|
||||
|
|
@ -37,9 +38,13 @@ export default defineConfig({
|
|||
},
|
||||
webServer: shouldStartWebServer
|
||||
? {
|
||||
command: `npm run dev -- --host ${host} --port ${port}`,
|
||||
// npm 11 + Windows PowerShell에서는 공백형 값이 npm 옵션으로 다시 파싱돼
|
||||
// `vite localhost 5174`처럼 위치 인자로 깨질 수 있다. 등호형으로 Vite에 고정한다.
|
||||
command: `npm run dev -- --host=${host} --port=${port}`,
|
||||
url: baseURL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
// 공개 preview나 다른 checkout의 stale bundle을 로컬 GREEN으로 오인하지 않는다.
|
||||
// 의도적으로 같은 dev server를 공유할 때만 명시적으로 opt in 한다.
|
||||
reuseExistingServer,
|
||||
timeout: 120_000,
|
||||
env: {
|
||||
VITE_API_BASE: process.env.VITE_API_BASE ?? "/api",
|
||||
|
|
|
|||
|
|
@ -471,6 +471,18 @@
|
|||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
/* 관리자처럼 세 역할을 모두 전환할 수 있는 계정도 테마·로그아웃 버튼을
|
||||
밀어내지 않게 한다. 링크 이름은 aria-label/title에 그대로 남는다. */
|
||||
.vg-topbar__switch-link {
|
||||
flex: 0 0 34px;
|
||||
padding: 0;
|
||||
}
|
||||
.vg-topbar__switch-link span {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Botanical glass canvas ── */
|
||||
body[data-role] .vg-shell--botanical .vg-shell__body:not(.vg-shell__body--bare) {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -128,6 +128,58 @@ export interface paths {
|
|||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/protocols": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
/** List Admin Protocols */
|
||||
get: operations["list_admin_protocols_admin_protocols_get"];
|
||||
put?: never;
|
||||
/** Create Admin Protocol */
|
||||
post: operations["create_admin_protocol_admin_protocols_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/protocols/{protocol_id}/activate": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Activate Admin Protocol */
|
||||
post: operations["activate_admin_protocol_admin_protocols__protocol_id__activate_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/protocols/{protocol_id}/retire": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: never;
|
||||
};
|
||||
get?: never;
|
||||
put?: never;
|
||||
/** Retire Admin Protocol */
|
||||
post: operations["retire_admin_protocol_admin_protocols__protocol_id__retire_post"];
|
||||
delete?: never;
|
||||
options?: never;
|
||||
head?: never;
|
||||
patch?: never;
|
||||
trace?: never;
|
||||
};
|
||||
"/admin/tickets": {
|
||||
parameters: {
|
||||
query?: never;
|
||||
|
|
@ -1218,7 +1270,8 @@ export interface paths {
|
|||
* @description 문서 인덱싱(관리자, RBAC ADMIN 강제). content_hash 증분 + 청크 임베딩 적재.
|
||||
*
|
||||
* ⚠️ 임베딩은 무거운 작업 → 본래 BackgroundTasks/배치 워커 위임 권장(202 Accepted).
|
||||
* DSM verbatim 저작권(license C/D)은 source 등록 시점 external_llm_ok 가드 책임.
|
||||
* source_id는 사전 등록된 kb.source만 허용하고, 라이선스와 프로토콜 active 상태는
|
||||
* 요청값이 아닌 DB 행으로 검증한다.
|
||||
* 모델 미가용 시 embedding NULL 폴백(BM25 만, degraded=True) — 크래시 X.
|
||||
*/
|
||||
post: operations["index_document_kb_index_post"];
|
||||
|
|
@ -2573,8 +2626,8 @@ export interface components {
|
|||
};
|
||||
/** ActualTransferExecutionResponse */
|
||||
ActualTransferExecutionResponse: {
|
||||
assessment: components["schemas"]["ActualTransferAssessment"];
|
||||
execution: components["schemas"]["ActualTransferExecution"];
|
||||
assessment?: components["schemas"]["ActualTransferAssessment"] | null;
|
||||
execution?: components["schemas"]["ActualTransferExecution"] | null;
|
||||
/** Idempotent Replay */
|
||||
idempotent_replay: boolean;
|
||||
};
|
||||
|
|
@ -2738,6 +2791,89 @@ export interface components {
|
|||
/** Smtp Configured */
|
||||
smtp_configured: boolean;
|
||||
};
|
||||
/** AdminProtocolActivationResponse */
|
||||
AdminProtocolActivationResponse: {
|
||||
/** Chunks Indexed */
|
||||
chunks_indexed: number;
|
||||
/** Degraded */
|
||||
degraded: boolean;
|
||||
/** Embedded */
|
||||
embedded: boolean;
|
||||
protocol: components["schemas"]["AdminProtocolResponse"];
|
||||
/** Skipped Unchanged */
|
||||
skipped_unchanged: boolean;
|
||||
};
|
||||
/** AdminProtocolCreate */
|
||||
AdminProtocolCreate: {
|
||||
/** Content */
|
||||
content: string;
|
||||
/**
|
||||
* External Llm Ok
|
||||
* @default false
|
||||
*/
|
||||
external_llm_ok: boolean;
|
||||
/**
|
||||
* License
|
||||
* @enum {string}
|
||||
*/
|
||||
license: "A" | "B" | "C" | "D";
|
||||
/** Source */
|
||||
source: string;
|
||||
/** Title */
|
||||
title: string;
|
||||
/**
|
||||
* Version
|
||||
* @default 1
|
||||
*/
|
||||
version: number;
|
||||
};
|
||||
/** AdminProtocolListResponse */
|
||||
AdminProtocolListResponse: {
|
||||
/** Protocols */
|
||||
protocols: components["schemas"]["AdminProtocolResponse"][];
|
||||
/** Total */
|
||||
total: number;
|
||||
};
|
||||
/** AdminProtocolResponse */
|
||||
AdminProtocolResponse: {
|
||||
/** Activated At */
|
||||
activated_at?: string | null;
|
||||
/** Content */
|
||||
content: string;
|
||||
/** Content Hash */
|
||||
content_hash: string;
|
||||
/** External Llm Ok */
|
||||
external_llm_ok: boolean;
|
||||
/**
|
||||
* License
|
||||
* @enum {string}
|
||||
*/
|
||||
license: "A" | "B" | "C" | "D";
|
||||
/** Protocol Id */
|
||||
protocol_id: string;
|
||||
/**
|
||||
* Registered At
|
||||
* Format: date-time
|
||||
*/
|
||||
registered_at: string;
|
||||
/** Registered By */
|
||||
registered_by: string;
|
||||
/** Retired At */
|
||||
retired_at?: string | null;
|
||||
/** Source */
|
||||
source: string;
|
||||
/** Source Id */
|
||||
source_id: string;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "draft" | "active" | "retired";
|
||||
/** Title */
|
||||
title: string;
|
||||
/** Version */
|
||||
version: number;
|
||||
};
|
||||
/** AdminServiceHealth */
|
||||
AdminServiceHealth: {
|
||||
/** Detail */
|
||||
|
|
@ -3073,10 +3209,11 @@ export interface components {
|
|||
AdminUserCreate: {
|
||||
/**
|
||||
* Account Status
|
||||
* @default approved
|
||||
* @default pending
|
||||
* @constant
|
||||
* @enum {string}
|
||||
*/
|
||||
account_status: "pending" | "approved" | "suspended";
|
||||
account_status: "pending";
|
||||
/**
|
||||
* Admin Access
|
||||
* @default false
|
||||
|
|
@ -3090,6 +3227,11 @@ export interface components {
|
|||
display_name: string;
|
||||
/** Email */
|
||||
email: string;
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/**
|
||||
* Role
|
||||
* @default learner
|
||||
|
|
@ -3116,6 +3258,8 @@ export interface components {
|
|||
cohort_ids?: string[] | null;
|
||||
/** Display Name */
|
||||
display_name?: string | null;
|
||||
/** Learner Feedback Enabled */
|
||||
learner_feedback_enabled?: boolean | null;
|
||||
/** Role */
|
||||
role?: ("learner" | "teacher" | "admin") | null;
|
||||
};
|
||||
|
|
@ -3142,6 +3286,8 @@ export interface components {
|
|||
email: string;
|
||||
/** Last Seen At */
|
||||
last_seen_at: number;
|
||||
/** Learner Feedback Enabled */
|
||||
learner_feedback_enabled: boolean;
|
||||
/**
|
||||
* Role
|
||||
* @enum {string}
|
||||
|
|
@ -5881,6 +6027,58 @@ export interface components {
|
|||
* @default runtime
|
||||
*/
|
||||
source: string;
|
||||
training_exposure?: components["schemas"]["LearnerDashboardTrainingExposure"];
|
||||
};
|
||||
/** LearnerDashboardTrainingExposure */
|
||||
LearnerDashboardTrainingExposure: {
|
||||
/**
|
||||
* Attention Threshold
|
||||
* @default 0.75
|
||||
*/
|
||||
attention_threshold: number;
|
||||
/**
|
||||
* Completed Sessions
|
||||
* @default 0
|
||||
*/
|
||||
completed_sessions: number;
|
||||
/**
|
||||
* Definition
|
||||
* @default 종료 회기의 페르소나별 최다 노출 비중을 보여 주는 투명한 훈련 노출 지표이며, 공정성 평가나 임상진단이 아닙니다.
|
||||
*/
|
||||
definition: string;
|
||||
/** Dominant Persona Code */
|
||||
dominant_persona_code?: string | null;
|
||||
/** Dominant Persona Name */
|
||||
dominant_persona_name?: string | null;
|
||||
/**
|
||||
* Dominant Sessions
|
||||
* @default 0
|
||||
*/
|
||||
dominant_sessions: number;
|
||||
/** Dominant Share */
|
||||
dominant_share?: number | null;
|
||||
/**
|
||||
* Label
|
||||
* @default 판정 근거 부족
|
||||
* @enum {string}
|
||||
*/
|
||||
label: "판정 근거 부족" | "훈련 집중 주의" | "균형";
|
||||
/**
|
||||
* Minimum Completed Sessions
|
||||
* @default 4
|
||||
*/
|
||||
minimum_completed_sessions: number;
|
||||
/**
|
||||
* Status
|
||||
* @default insufficient
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "insufficient" | "attention" | "balanced";
|
||||
/**
|
||||
* Version
|
||||
* @default training-exposure-dominant-share.v1
|
||||
*/
|
||||
version: string;
|
||||
};
|
||||
/** LearnerRefMapping */
|
||||
LearnerRefMapping: {
|
||||
|
|
@ -5905,6 +6103,11 @@ export interface components {
|
|||
client_turn_count: number;
|
||||
/** Ended At */
|
||||
ended_at?: string | null;
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/** Learner Turn Count */
|
||||
learner_turn_count: number;
|
||||
/** Persona Code */
|
||||
|
|
@ -8678,6 +8881,62 @@ export interface components {
|
|||
/** Persona */
|
||||
persona: string;
|
||||
};
|
||||
/** ReviewFirstSessionChecklist */
|
||||
ReviewFirstSessionChecklist: {
|
||||
/**
|
||||
* Applicable
|
||||
* @default true
|
||||
*/
|
||||
applicable: boolean;
|
||||
/** Criteria */
|
||||
criteria?: components["schemas"]["ReviewFirstSessionChecklistCriterion"][];
|
||||
/**
|
||||
* Note
|
||||
* @default 축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다.
|
||||
*/
|
||||
note: string;
|
||||
/**
|
||||
* Status
|
||||
* @default ready
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "ready" | "not_applicable";
|
||||
/**
|
||||
* Title
|
||||
* @default 첫 회기 라포·개방질문 체크리스트
|
||||
*/
|
||||
title: string;
|
||||
/**
|
||||
* Version
|
||||
* @default first-session-rapport-open-question.v1
|
||||
*/
|
||||
version: string;
|
||||
};
|
||||
/** ReviewFirstSessionChecklistCriterion */
|
||||
ReviewFirstSessionChecklistCriterion: {
|
||||
/** Criterionid */
|
||||
criterionId: string;
|
||||
/** Description */
|
||||
description: string;
|
||||
/** Evidenceturns */
|
||||
evidenceTurns?: components["schemas"]["ReviewFirstSessionChecklistEvidence"][];
|
||||
/** Label */
|
||||
label: string;
|
||||
/**
|
||||
* Status
|
||||
* @enum {string}
|
||||
*/
|
||||
status: "met" | "not_observed";
|
||||
};
|
||||
/** ReviewFirstSessionChecklistEvidence */
|
||||
ReviewFirstSessionChecklistEvidence: {
|
||||
/** Quote */
|
||||
quote: string;
|
||||
/** Turnid */
|
||||
turnId: string;
|
||||
/** Turnseq */
|
||||
turnSeq: number;
|
||||
};
|
||||
/** ReviewNonverbalEvent */
|
||||
ReviewNonverbalEvent: {
|
||||
/** Detail */
|
||||
|
|
@ -9097,10 +9356,19 @@ export interface components {
|
|||
ended_at?: string | null;
|
||||
/** Goal Stages */
|
||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/** Persona Code */
|
||||
persona_code: string;
|
||||
/** Persona Id */
|
||||
persona_id?: string | null;
|
||||
/** Persona Name */
|
||||
persona_name: string;
|
||||
/** Persona Version */
|
||||
persona_version?: number | null;
|
||||
progress?: components["schemas"]["SessionProgress"] | null;
|
||||
/**
|
||||
* Review Ready
|
||||
|
|
@ -9260,10 +9528,16 @@ export interface components {
|
|||
durationLabel: string;
|
||||
/** Durationseconds */
|
||||
durationSeconds: number;
|
||||
firstSessionChecklist?: components["schemas"]["ReviewFirstSessionChecklist"] | null;
|
||||
/** Goodmoments */
|
||||
goodMoments?: components["schemas"]["ReviewPoint"][];
|
||||
/** Growthpoints */
|
||||
growthPoints?: components["schemas"]["ReviewPoint"][];
|
||||
/**
|
||||
* Learnerfeedbackenabled
|
||||
* @default true
|
||||
*/
|
||||
learnerFeedbackEnabled: boolean;
|
||||
/** Nextline */
|
||||
nextLine?: string | null;
|
||||
/** Pdfexporturl */
|
||||
|
|
@ -9284,6 +9558,8 @@ export interface components {
|
|||
reviewReady: boolean;
|
||||
/** Rubric */
|
||||
rubric?: components["schemas"]["ReviewRubricRow"][];
|
||||
/** Sessionno */
|
||||
sessionNo: number;
|
||||
/** Sessionsignal */
|
||||
sessionSignal: string;
|
||||
/** Session Id */
|
||||
|
|
@ -9378,6 +9654,15 @@ export interface components {
|
|||
effective_openness: number;
|
||||
/** Goal Stages */
|
||||
goal_stages?: ("라포" | "탐색" | "개입" | "정리")[];
|
||||
/**
|
||||
* Learner Feedback Enabled
|
||||
* @default true
|
||||
*/
|
||||
learner_feedback_enabled: boolean;
|
||||
/** Persona Id */
|
||||
persona_id: string;
|
||||
/** Persona Version */
|
||||
persona_version: number;
|
||||
/** Recall Summary */
|
||||
recall_summary?: string | null;
|
||||
/** Session Id */
|
||||
|
|
@ -11244,6 +11529,145 @@ export interface operations {
|
|||
};
|
||||
};
|
||||
};
|
||||
list_admin_protocols_admin_protocols_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
status?: ("draft" | "active" | "retired") | null;
|
||||
search?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolListResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
create_admin_protocol_admin_protocols_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody: {
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolCreate"];
|
||||
};
|
||||
};
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
201: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
activate_admin_protocol_admin_protocols__protocol_id__activate_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
protocol_id: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolActivationResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
retire_admin_protocol_admin_protocols__protocol_id__retire_post: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
header?: never;
|
||||
path: {
|
||||
protocol_id: string;
|
||||
};
|
||||
cookie?: {
|
||||
"__Host-vignette_sid"?: string | null;
|
||||
vignette_sid?: string | null;
|
||||
};
|
||||
};
|
||||
requestBody?: never;
|
||||
responses: {
|
||||
/** @description Successful Response */
|
||||
200: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["AdminProtocolResponse"];
|
||||
};
|
||||
};
|
||||
/** @description Validation Error */
|
||||
422: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["HTTPValidationError"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
list_tickets_admin_tickets_get: {
|
||||
parameters: {
|
||||
query?: {
|
||||
|
|
@ -11841,7 +12265,9 @@ export interface operations {
|
|||
};
|
||||
get_my_calibration_transfer_calibration_learners_me_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
session_id?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
cookie?: {
|
||||
|
|
@ -14361,7 +14787,9 @@ export interface operations {
|
|||
};
|
||||
get_multimodal_session_metadata_sessions__session_id__multimodal_alliance_get: {
|
||||
parameters: {
|
||||
query?: never;
|
||||
query?: {
|
||||
include_derived?: boolean;
|
||||
};
|
||||
header?: never;
|
||||
path: {
|
||||
session_id: string;
|
||||
|
|
|
|||
|
|
@ -252,6 +252,7 @@ export type LearnerDashboardResponse = ApiSchema<"LearnerDashboardResponse">;
|
|||
export type LearnerDashboardPersonaProgress = ApiSchema<"LearnerDashboardPersonaProgress">;
|
||||
export type LearnerDashboardAchievement = ApiSchema<"LearnerDashboardAchievement">;
|
||||
export type LearnerDashboardFeedbackItem = ApiSchema<"LearnerDashboardFeedbackItem">;
|
||||
export type LearnerDashboardTrainingExposure = ApiSchema<"LearnerDashboardTrainingExposure">;
|
||||
|
||||
export type SessionDetailTurn = ApiSchema<"SessionDetailTurn">;
|
||||
|
||||
|
|
@ -274,6 +275,9 @@ export type ReviewWorksheetItem = ApiSchema<"ReviewWorksheetItem">;
|
|||
export type ReviewWorksheetSection = ApiSchema<"ReviewWorksheetSection-Output">;
|
||||
export type ReviewCaseWorksheet = ApiSchema<"ReviewCaseWorksheet">;
|
||||
export type ReviewCaseWorksheetSaveRequest = ApiSchema<"ReviewCaseWorksheetSaveRequest">;
|
||||
export type ReviewFirstSessionChecklistEvidence = ApiSchema<"ReviewFirstSessionChecklistEvidence">;
|
||||
export type ReviewFirstSessionChecklistCriterion = ApiSchema<"ReviewFirstSessionChecklistCriterion">;
|
||||
export type ReviewFirstSessionChecklist = ApiSchema<"ReviewFirstSessionChecklist">;
|
||||
export type SessionReviewResponse = ApiSchema<"SessionReviewResponse">;
|
||||
export type SessionShareResponse = ApiSchema<"SessionShareResponse">;
|
||||
export type SessionShareDeleteResponse = ApiSchema<"SessionShareDeleteResponse">;
|
||||
|
|
@ -441,7 +445,12 @@ export async function openSessionStream(
|
|||
processBuffer(true);
|
||||
|
||||
if (streamError) throw streamError;
|
||||
return donePayload ?? { session_id: sessionId };
|
||||
if (!donePayload) {
|
||||
const detail = "client_stream_incomplete";
|
||||
handlers.onError?.({ detail });
|
||||
throw new ApiError(503, detail, { code: detail });
|
||||
}
|
||||
return donePayload;
|
||||
}
|
||||
|
||||
/* === 세션 API 헬퍼 (Features 단계 Session 페이지가 사용) === */
|
||||
|
|
@ -643,6 +652,34 @@ export const adminUsersApi = {
|
|||
}),
|
||||
};
|
||||
|
||||
export type AdminProtocol = ApiSchema<"AdminProtocolResponse">;
|
||||
export type AdminProtocolCreateRequest = ApiSchema<"AdminProtocolCreate">;
|
||||
export type AdminProtocolListResponse = ApiSchema<"AdminProtocolListResponse">;
|
||||
export type AdminProtocolActivationResponse = ApiSchema<"AdminProtocolActivationResponse">;
|
||||
export type AdminProtocolStatus = AdminProtocol["status"];
|
||||
|
||||
export const adminProtocolsApi = {
|
||||
list: (filters: { status?: AdminProtocolStatus | ""; search?: string } = {}) => {
|
||||
const params = new URLSearchParams();
|
||||
if (filters.status) params.set("status", filters.status);
|
||||
if (filters.search?.trim()) params.set("search", filters.search.trim());
|
||||
const query = params.toString();
|
||||
return api.get<AdminProtocolListResponse>(`/admin/protocols${query ? `?${query}` : ""}`);
|
||||
},
|
||||
create: (body: AdminProtocolCreateRequest) =>
|
||||
api.post<AdminProtocol>("/admin/protocols", body),
|
||||
activate: (protocolId: string) =>
|
||||
api.post<AdminProtocolActivationResponse>(
|
||||
`/admin/protocols/${encodeURIComponent(protocolId)}/activate`,
|
||||
{},
|
||||
),
|
||||
retire: (protocolId: string) =>
|
||||
api.post<AdminProtocol>(
|
||||
`/admin/protocols/${encodeURIComponent(protocolId)}/retire`,
|
||||
{},
|
||||
),
|
||||
};
|
||||
|
||||
export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
|
||||
|
||||
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
const DISPLAY_PLACEHOLDERS: Record<string, string> = {
|
||||
"[NAME]": "익명 내담자",
|
||||
"[COUNSELOR]": "상담자",
|
||||
"[CLIENT]": "내담자",
|
||||
"[NAME]": "익명 인물",
|
||||
"[ORG]": "소속 기관",
|
||||
"[PHONE]": "연락처",
|
||||
"[EMAIL]": "이메일",
|
||||
|
|
|
|||
|
|
@ -29,10 +29,14 @@ import {
|
|||
} from "../components/ui";
|
||||
import {
|
||||
adminApi,
|
||||
adminProtocolsApi,
|
||||
adminUsersApi,
|
||||
type AdminHealthResponse,
|
||||
type AdminHealthStatus,
|
||||
type AdminManagedUser,
|
||||
type AdminProtocol,
|
||||
type AdminProtocolCreateRequest,
|
||||
type AdminProtocolStatus,
|
||||
type AdminTicketFilters,
|
||||
type AdminSupportTicket,
|
||||
type AdminTicketsResponse,
|
||||
|
|
@ -58,6 +62,7 @@ type UserDraft = Pick<
|
|||
| "display_name"
|
||||
| "role"
|
||||
| "admin_access"
|
||||
| "learner_feedback_enabled"
|
||||
| "account_status"
|
||||
| "affiliation"
|
||||
| "cohort_ids"
|
||||
|
|
@ -67,10 +72,17 @@ type NewUserDraft = Required<
|
|||
> &
|
||||
Pick<
|
||||
UserDraft,
|
||||
"admin_access" | "account_status" | "affiliation" | "cohort_ids"
|
||||
>;
|
||||
| "admin_access"
|
||||
| "learner_feedback_enabled"
|
||||
| "affiliation"
|
||||
| "cohort_ids"
|
||||
> & {
|
||||
account_status: "pending";
|
||||
};
|
||||
type UserTab = "approval" | "manage" | "register" | "activity";
|
||||
type AccessTab = "roles" | "groups" | "matrix";
|
||||
type AccessTab = "roles" | "groups" | "matrix" | "protocols";
|
||||
type ProtocolStatusFilter = AdminProtocolStatus | "all";
|
||||
type ProtocolDraft = AdminProtocolCreateRequest;
|
||||
type TicketStatusFilter = AdminSupportTicket["status"] | "all";
|
||||
type TicketCategoryFilter = AdminSupportTicket["category"] | "all";
|
||||
type TicketPriorityFilter = AdminSupportTicket["priority"] | "all";
|
||||
|
|
@ -116,11 +128,46 @@ const EMPTY_NEW_USER: NewUserDraft = {
|
|||
display_name: "",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
account_status: "approved",
|
||||
learner_feedback_enabled: true,
|
||||
account_status: "pending",
|
||||
affiliation: "",
|
||||
cohort_ids: [],
|
||||
};
|
||||
|
||||
const EMPTY_PROTOCOL_DRAFT: ProtocolDraft = {
|
||||
title: "",
|
||||
source: "",
|
||||
version: 1,
|
||||
license: "B",
|
||||
external_llm_ok: false,
|
||||
content: "",
|
||||
};
|
||||
|
||||
function protocolStatusLabel(status: AdminProtocolStatus): string {
|
||||
if (status === "draft") return "초안";
|
||||
if (status === "active") return "활성";
|
||||
return "퇴역";
|
||||
}
|
||||
|
||||
function protocolStatusTone(
|
||||
status: AdminProtocolStatus,
|
||||
): "warn" | "pos" | "neutral" {
|
||||
if (status === "draft") return "warn";
|
||||
if (status === "active") return "pos";
|
||||
return "neutral";
|
||||
}
|
||||
|
||||
function protocolAllowsExternalLlm(license: ProtocolDraft["license"]): boolean {
|
||||
return license === "A" || license === "B";
|
||||
}
|
||||
|
||||
function protocolVersionError(version: number): string | null {
|
||||
if (!Number.isInteger(version) || version < 1 || version > 1_000_000) {
|
||||
return "버전은 1부터 1,000,000 사이의 정수여야 합니다.";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const ROLE_POLICIES = [
|
||||
{
|
||||
role: "관리자",
|
||||
|
|
@ -516,6 +563,7 @@ function userDraftFrom(user: AdminManagedUser): UserDraft {
|
|||
display_name: user.display_name,
|
||||
role: user.role,
|
||||
admin_access: user.admin_access,
|
||||
learner_feedback_enabled: user.learner_feedback_enabled ?? true,
|
||||
account_status: user.account_status,
|
||||
affiliation: user.affiliation,
|
||||
cohort_ids: user.cohort_ids,
|
||||
|
|
@ -827,6 +875,18 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const [ticketStaleOnly, setTicketStaleOnly] = useState(false);
|
||||
const [userTab, setUserTab] = useState<UserTab>("approval");
|
||||
const [accessTab, setAccessTab] = useState<AccessTab>("roles");
|
||||
const [protocols, setProtocols] = useState<AdminProtocol[]>([]);
|
||||
const [protocolsLoaded, setProtocolsLoaded] = useState(false);
|
||||
const [protocolsLoading, setProtocolsLoading] = useState(false);
|
||||
const [protocolsError, setProtocolsError] = useState<string | null>(null);
|
||||
const [protocolDraft, setProtocolDraft] = useState<ProtocolDraft>(
|
||||
EMPTY_PROTOCOL_DRAFT,
|
||||
);
|
||||
const [protocolSaving, setProtocolSaving] = useState(false);
|
||||
const [protocolActionId, setProtocolActionId] = useState<string | null>(null);
|
||||
const [protocolSearch, setProtocolSearch] = useState("");
|
||||
const [protocolStatusFilter, setProtocolStatusFilter] =
|
||||
useState<ProtocolStatusFilter>("all");
|
||||
const canGrantAdminAccess = currentUser?.superAdmin === true;
|
||||
|
||||
const loadHealth = useCallback(async () => {
|
||||
|
|
@ -940,6 +1000,24 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
ticketStatusFilter,
|
||||
]);
|
||||
|
||||
const loadProtocols = useCallback(async () => {
|
||||
setProtocolsLoading(true);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const response = await adminProtocolsApi.list();
|
||||
setProtocols(response.protocols);
|
||||
setProtocolsLoaded(true);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "상담 프로토콜 목록을 불러오지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setProtocolsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void loadHealth();
|
||||
void loadUsers();
|
||||
|
|
@ -951,6 +1029,12 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
void loadTickets();
|
||||
}, [loadTickets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (section === "access" && accessTab === "protocols" && !protocolsLoaded) {
|
||||
void loadProtocols();
|
||||
}
|
||||
}, [accessTab, loadProtocols, protocolsLoaded, section]);
|
||||
|
||||
const refreshAll = useCallback(async () => {
|
||||
await Promise.all([
|
||||
loadHealth(),
|
||||
|
|
@ -961,6 +1045,109 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
]);
|
||||
}, [loadHealth, loadTickets, loadUptime, loadUsage, loadUsers]);
|
||||
|
||||
const visibleProtocols = useMemo(() => {
|
||||
const query = protocolSearch.trim().toLocaleLowerCase("ko-KR");
|
||||
return protocols.filter((protocol) => {
|
||||
if (
|
||||
protocolStatusFilter !== "all" &&
|
||||
protocol.status !== protocolStatusFilter
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (!query) return true;
|
||||
return [protocol.title, protocol.source, protocol.source_id]
|
||||
.join(" ")
|
||||
.toLocaleLowerCase("ko-KR")
|
||||
.includes(query);
|
||||
});
|
||||
}, [protocolSearch, protocolStatusFilter, protocols]);
|
||||
|
||||
const createProtocol = async () => {
|
||||
if (
|
||||
!protocolDraft.title.trim() ||
|
||||
!protocolDraft.source.trim() ||
|
||||
!protocolDraft.content.trim()
|
||||
) {
|
||||
setProtocolsError("제목, 출처, 원문을 모두 입력해야 합니다.");
|
||||
return;
|
||||
}
|
||||
const versionError = protocolVersionError(protocolDraft.version);
|
||||
if (versionError) {
|
||||
setProtocolsError(versionError);
|
||||
return;
|
||||
}
|
||||
setProtocolSaving(true);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const created = await adminProtocolsApi.create({
|
||||
...protocolDraft,
|
||||
title: protocolDraft.title.trim(),
|
||||
source: protocolDraft.source.trim(),
|
||||
content: protocolDraft.content.trim(),
|
||||
external_llm_ok:
|
||||
protocolAllowsExternalLlm(protocolDraft.license)
|
||||
? protocolDraft.external_llm_ok
|
||||
: false,
|
||||
});
|
||||
setProtocols((current) => [
|
||||
created,
|
||||
...current.filter((item) => item.protocol_id !== created.protocol_id),
|
||||
]);
|
||||
setProtocolDraft(EMPTY_PROTOCOL_DRAFT);
|
||||
setProtocolsLoaded(true);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: "상담 프로토콜 초안을 등록하지 못했습니다.",
|
||||
);
|
||||
} finally {
|
||||
setProtocolSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activateProtocol = async (protocol: AdminProtocol) => {
|
||||
setProtocolActionId(protocol.protocol_id);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const response = await adminProtocolsApi.activate(protocol.protocol_id);
|
||||
setProtocols((current) =>
|
||||
current.map((item) =>
|
||||
item.protocol_id === protocol.protocol_id ? response.protocol : item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `“${protocol.title}” 프로토콜을 활성화하지 못했습니다.`,
|
||||
);
|
||||
} finally {
|
||||
setProtocolActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const retireProtocol = async (protocol: AdminProtocol) => {
|
||||
setProtocolActionId(protocol.protocol_id);
|
||||
setProtocolsError(null);
|
||||
try {
|
||||
const retired = await adminProtocolsApi.retire(protocol.protocol_id);
|
||||
setProtocols((current) =>
|
||||
current.map((item) =>
|
||||
item.protocol_id === protocol.protocol_id ? retired : item,
|
||||
),
|
||||
);
|
||||
} catch (err) {
|
||||
setProtocolsError(
|
||||
err instanceof Error
|
||||
? err.message
|
||||
: `“${protocol.title}” 프로토콜을 퇴역하지 못했습니다.`,
|
||||
);
|
||||
} finally {
|
||||
setProtocolActionId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const updateDraft = (userId: string, patch: Partial<UserDraft>) => {
|
||||
setUserDrafts((current) => ({
|
||||
...current,
|
||||
|
|
@ -969,6 +1156,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
display_name: "",
|
||||
role: "learner",
|
||||
admin_access: false,
|
||||
learner_feedback_enabled: true,
|
||||
account_status: "approved",
|
||||
affiliation: "",
|
||||
cohort_ids: [],
|
||||
|
|
@ -1017,6 +1205,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
display_name: displayName,
|
||||
affiliation: newUser.affiliation.trim(),
|
||||
cohort_ids: newUser.cohort_ids,
|
||||
account_status: "pending",
|
||||
});
|
||||
setNewUser(EMPTY_NEW_USER);
|
||||
await loadUsers();
|
||||
|
|
@ -1388,6 +1577,31 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "learner_feedback_enabled",
|
||||
accessorFn: (user) => Number(user.learner_feedback_enabled),
|
||||
header: "AI 피드백",
|
||||
cell: ({ row }) => {
|
||||
const user = row.original;
|
||||
const draft = userDrafts[user.user_id] ?? user;
|
||||
return (
|
||||
<label className="vgops-user-table__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draft.learner_feedback_enabled}
|
||||
onChange={(event) =>
|
||||
updateDraft(user.user_id, {
|
||||
learner_feedback_enabled: event.target.checked,
|
||||
})
|
||||
}
|
||||
disabled={!usersWritable || savingUserId === user.user_id}
|
||||
aria-label={`${user.email} 학습자 AI 피드백`}
|
||||
/>
|
||||
<span>{draft.learner_feedback_enabled ? "켜짐" : "꺼짐"}</span>
|
||||
</label>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "affiliation",
|
||||
accessorFn: (user) => user.affiliation,
|
||||
|
|
@ -1465,6 +1679,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
draft.display_name !== user.display_name ||
|
||||
draft.role !== user.role ||
|
||||
draft.admin_access !== user.admin_access ||
|
||||
draft.learner_feedback_enabled !==
|
||||
(user.learner_feedback_enabled ?? true) ||
|
||||
draft.account_status !== user.account_status ||
|
||||
draft.affiliation !== user.affiliation ||
|
||||
cohortInputValue(draft.cohort_ids) !==
|
||||
|
|
@ -1868,7 +2084,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
`가입 승인${accountCounts.pending ? ` ${accountCounts.pending}` : ""}`,
|
||||
],
|
||||
["manage", "사용자 목록"],
|
||||
["register", "사용자 등록"],
|
||||
["register", "외부 연구참여자 사전등록"],
|
||||
["activity", "활동 요약"],
|
||||
]}
|
||||
value={userTab}
|
||||
|
|
@ -2012,12 +2228,13 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
const renderUserCreate = () => (
|
||||
<section className={surfaceClassName("vgops-panel")}>
|
||||
<div className="vgops-section__head">
|
||||
<h2>새 사용자 등록</h2>
|
||||
<h2>외부 연구참여자 사전등록</h2>
|
||||
<span>{usersWritable ? "DB 저장 가능" : "읽기 전용"}</span>
|
||||
</div>
|
||||
<div className="vgops-users-note">
|
||||
역할과 코호트는 접근 범위를 결정합니다. 허용 도메인 밖 이메일은 정확히
|
||||
등록된 계정만 로그인 가능합니다.
|
||||
허용 도메인 밖 참여자는 관리자가 입력한 정확한 이메일 주소로만 Google·개발
|
||||
로그인을 통과합니다. 사전등록 계정은 승인 대기 상태이며, 승인 전에는 학습
|
||||
기능에 접근할 수 없습니다.
|
||||
</div>
|
||||
{!usersWritable ? (
|
||||
<div className="vgops-users-note vgops-users-note--warn" role="alert">
|
||||
|
|
@ -2034,6 +2251,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
<label>
|
||||
<span>이메일</span>
|
||||
<input
|
||||
type="email"
|
||||
value={newUser.email}
|
||||
onChange={(event) =>
|
||||
setNewUser((current) => ({
|
||||
|
|
@ -2041,7 +2259,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
email: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="name@example.com"
|
||||
placeholder="participant@example.com"
|
||||
autoComplete="email"
|
||||
disabled={!usersWritable || creatingUser}
|
||||
aria-label="새 사용자 이메일"
|
||||
/>
|
||||
|
|
@ -2100,24 +2319,30 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
/>
|
||||
<span>관리자 페이지 권한</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>승인 상태</span>
|
||||
<select
|
||||
value={newUser.account_status}
|
||||
<label className="vgops-checkline">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={newUser.learner_feedback_enabled}
|
||||
onChange={(event) =>
|
||||
setNewUser((current) => ({
|
||||
...current,
|
||||
account_status: event.target
|
||||
.value as AdminManagedUser["account_status"],
|
||||
learner_feedback_enabled: event.target.checked,
|
||||
}))
|
||||
}
|
||||
disabled={!usersWritable || creatingUser}
|
||||
aria-label="새 사용자 학습자 AI 피드백"
|
||||
/>
|
||||
<span>학습자 AI 피드백</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>승인 상태</span>
|
||||
<output
|
||||
className="vgops-readonly-control"
|
||||
aria-label="새 사용자 승인 상태"
|
||||
data-value="pending"
|
||||
>
|
||||
<option value="approved">승인됨</option>
|
||||
<option value="pending">승인 대기</option>
|
||||
<option value="suspended">보류</option>
|
||||
</select>
|
||||
승인 대기
|
||||
</output>
|
||||
</label>
|
||||
<label>
|
||||
<span>코호트</span>
|
||||
|
|
@ -2154,7 +2379,7 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
leading={<Icon name="users" size={15} />}
|
||||
disabled={!usersWritable || creatingUser}
|
||||
>
|
||||
{creatingUser ? "등록 중" : "사용자 등록"}
|
||||
{creatingUser ? "사전등록 중" : "연구참여자 사전등록"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
|
@ -2354,23 +2579,303 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
</section>
|
||||
);
|
||||
|
||||
const renderProtocols = () => (
|
||||
<section className="vgops-protocols" aria-label="상담 프로토콜 관리">
|
||||
<section className={surfaceClassName("vgops-panel vgops-protocol-create") }>
|
||||
<div className="vgops-section__head">
|
||||
<h2>상담 프로토콜 초안 등록</h2>
|
||||
<Badge tone="neutral">평가자 전용</Badge>
|
||||
</div>
|
||||
<p className="vgops-protocol-intro">
|
||||
초안은 활성화할 때만 RAG에 색인되며, 라이선스 C/D 원문은 외부 LLM에
|
||||
전달할 수 없습니다.
|
||||
</p>
|
||||
<form
|
||||
className="vgops-protocol-form"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
void createProtocol();
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
<span>제목</span>
|
||||
<input
|
||||
required
|
||||
value={protocolDraft.title}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
title: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 제목"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<label className="vgops-protocol-form__source">
|
||||
<span>출처</span>
|
||||
<input
|
||||
required
|
||||
value={protocolDraft.source}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
source: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="공식 문서 URL 또는 출처 식별자"
|
||||
aria-label="프로토콜 출처"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>버전</span>
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={1_000_000}
|
||||
required
|
||||
value={protocolDraft.version}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
version: Number(event.target.value),
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 버전"
|
||||
aria-invalid={protocolVersionError(protocolDraft.version) !== null}
|
||||
aria-describedby="protocol-version-error"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
{protocolVersionError(protocolDraft.version) ? (
|
||||
<small
|
||||
id="protocol-version-error"
|
||||
className="vgops-protocol-field-error"
|
||||
role="alert"
|
||||
>
|
||||
{protocolVersionError(protocolDraft.version)}
|
||||
</small>
|
||||
) : null}
|
||||
</label>
|
||||
<label>
|
||||
<span>라이선스</span>
|
||||
<select
|
||||
value={protocolDraft.license}
|
||||
onChange={(event) => {
|
||||
const license = event.target.value as ProtocolDraft["license"];
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
license,
|
||||
external_llm_ok:
|
||||
protocolAllowsExternalLlm(license)
|
||||
? current.external_llm_ok
|
||||
: false,
|
||||
}));
|
||||
}}
|
||||
aria-label="프로토콜 라이선스"
|
||||
disabled={protocolSaving}
|
||||
>
|
||||
{(["A", "B", "C", "D"] as const).map((license) => (
|
||||
<option value={license} key={license}>
|
||||
{license}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="vgops-protocol-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={protocolDraft.external_llm_ok}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
external_llm_ok: event.target.checked,
|
||||
}))
|
||||
}
|
||||
aria-label="외부 LLM 사용 허용"
|
||||
disabled={
|
||||
protocolSaving ||
|
||||
protocolDraft.license === "C" ||
|
||||
protocolDraft.license === "D"
|
||||
}
|
||||
/>
|
||||
<span>외부 LLM 사용 허용</span>
|
||||
</label>
|
||||
<p className="vgops-protocol-policy" aria-live="polite">
|
||||
{protocolDraft.license === "C" || protocolDraft.license === "D"
|
||||
? "라이선스 C/D는 정책상 외부 LLM 사용이 차단됩니다."
|
||||
: "A/B도 명시적으로 허용한 경우에만 외부 LLM에 전달됩니다."}
|
||||
</p>
|
||||
<label className="vgops-protocol-form__content">
|
||||
<span>프로토콜 원문</span>
|
||||
<textarea
|
||||
required
|
||||
value={protocolDraft.content}
|
||||
onChange={(event) =>
|
||||
setProtocolDraft((current) => ({
|
||||
...current,
|
||||
content: event.target.value,
|
||||
}))
|
||||
}
|
||||
aria-label="프로토콜 원문"
|
||||
disabled={protocolSaving}
|
||||
/>
|
||||
</label>
|
||||
<Button
|
||||
className="vgops-protocol-submit"
|
||||
type="submit"
|
||||
disabled={protocolSaving}
|
||||
leading={<Icon name="plus" size={16} />}
|
||||
>
|
||||
{protocolSaving ? "초안 등록 중" : "초안 등록"}
|
||||
</Button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section className={surfaceClassName("vgops-panel vgops-protocol-list") }>
|
||||
<div className="vgops-section__head">
|
||||
<h2>등록 프로토콜</h2>
|
||||
<span>{protocols.length}개</span>
|
||||
</div>
|
||||
<div className="vgops-protocol-toolbar">
|
||||
<label>
|
||||
<span>검색</span>
|
||||
<input
|
||||
type="search"
|
||||
value={protocolSearch}
|
||||
onChange={(event) => setProtocolSearch(event.target.value)}
|
||||
placeholder="제목 또는 출처"
|
||||
aria-label="프로토콜 검색"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span>상태</span>
|
||||
<select
|
||||
value={protocolStatusFilter}
|
||||
onChange={(event) =>
|
||||
setProtocolStatusFilter(event.target.value as ProtocolStatusFilter)
|
||||
}
|
||||
aria-label="프로토콜 상태 필터"
|
||||
>
|
||||
<option value="all">전체</option>
|
||||
<option value="draft">초안</option>
|
||||
<option value="active">활성</option>
|
||||
<option value="retired">퇴역</option>
|
||||
</select>
|
||||
</label>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadProtocols()}
|
||||
disabled={protocolsLoading}
|
||||
>
|
||||
{protocolsLoading ? "불러오는 중" : "새로고침"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{protocolsError ? (
|
||||
<div className="vgops-protocol-error">
|
||||
<InlineError message={protocolsError} />
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => void loadProtocols()}
|
||||
disabled={protocolsLoading}
|
||||
>
|
||||
목록 다시 시도
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{protocolsLoading && !protocolsLoaded ? (
|
||||
<div className="vgops-users-note" role="status">
|
||||
상담 프로토콜 목록을 불러오는 중입니다.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!protocolsLoading && protocolsLoaded && visibleProtocols.length === 0 ? (
|
||||
<EmptyState
|
||||
title="조건에 맞는 프로토콜이 없습니다"
|
||||
body="새 초안을 등록하거나 검색·상태 필터를 바꿔 주세요."
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className="vgops-protocol-cards">
|
||||
{visibleProtocols.map((protocol) => (
|
||||
<article
|
||||
className={surfaceClassName("vgops-protocol-card", {
|
||||
variant: "inset",
|
||||
})}
|
||||
key={protocol.protocol_id}
|
||||
data-protocol-status={protocol.status}
|
||||
>
|
||||
<div className="vgops-protocol-card__copy">
|
||||
<div className="vgops-protocol-card__title">
|
||||
<h3>{protocol.title}</h3>
|
||||
<Badge tone={protocolStatusTone(protocol.status)}>
|
||||
{protocolStatusLabel(protocol.status)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p>{protocol.source}</p>
|
||||
<div className="vgops-chip-row">
|
||||
<span>v{protocol.version}</span>
|
||||
<span>라이선스 {protocol.license}</span>
|
||||
<span>
|
||||
외부 LLM {protocol.external_llm_ok ? "허용" : "차단"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="vgops-protocol-card__actions">
|
||||
{protocol.status === "draft" ? (
|
||||
<Button
|
||||
onClick={() => void activateProtocol(protocol)}
|
||||
disabled={protocolActionId === protocol.protocol_id}
|
||||
aria-label={`${protocol.title} 활성화`}
|
||||
>
|
||||
{protocolActionId === protocol.protocol_id
|
||||
? "활성화 중"
|
||||
: "활성화"}
|
||||
</Button>
|
||||
) : null}
|
||||
{protocol.status === "active" ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => void retireProtocol(protocol)}
|
||||
disabled={protocolActionId === protocol.protocol_id}
|
||||
aria-label={`${protocol.title} 퇴역`}
|
||||
>
|
||||
{protocolActionId === protocol.protocol_id ? "퇴역 중" : "퇴역"}
|
||||
</Button>
|
||||
) : null}
|
||||
{protocol.status === "retired" ? (
|
||||
<span className="vgops-protocol-card__terminal">변경 불가</span>
|
||||
) : null}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>
|
||||
);
|
||||
|
||||
const renderAccess = () => (
|
||||
<>
|
||||
{/* eyebrow "접근 권한"은 아래 "역할, 그룹, 접근 범위"와 같은 말이라 제거 */}
|
||||
<PageHeader
|
||||
title="역할, 그룹, 접근 범위"
|
||||
description="운영자가 누구에게 어떤 작업 권한을 줄지 판단하는 정책 화면입니다."
|
||||
description="운영 권한 정책과 평가용 상담 프로토콜의 수명주기를 관리합니다."
|
||||
/>
|
||||
{accessTab !== "protocols" ? (
|
||||
<div className="vgops-users-note">
|
||||
역할·그룹 저장 API는 아직 분리되지 않았습니다. 현재 화면은 운영 정책
|
||||
기준을 먼저 고정합니다.
|
||||
</div>
|
||||
) : null}
|
||||
<TabBar
|
||||
ariaLabel="접근 권한 탭"
|
||||
items={[
|
||||
["roles", "역할"],
|
||||
["groups", "그룹"],
|
||||
["matrix", "권한 매트릭스"],
|
||||
["protocols", "상담 프로토콜"],
|
||||
]}
|
||||
value={accessTab}
|
||||
onChange={setAccessTab}
|
||||
|
|
@ -2447,6 +2952,8 @@ export default function Admin({ section = "overview" }: AdminProps) {
|
|||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{accessTab === "protocols" ? renderProtocols() : null}
|
||||
</>
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -427,6 +427,11 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
);
|
||||
const topPersona =
|
||||
personaProgressRows.find((row) => row.total > 0) ?? personaProgressRows[0];
|
||||
const trainingExposure = dashboard?.training_exposure;
|
||||
const dominantExposurePercent =
|
||||
typeof trainingExposure?.dominant_share === "number"
|
||||
? Math.round(trainingExposure.dominant_share * 100)
|
||||
: null;
|
||||
const personaCatalogUnavailable = loadState === "ready" && !selected;
|
||||
const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0;
|
||||
const rootClassName = [
|
||||
|
|
@ -1596,14 +1601,15 @@ export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) {
|
|||
</div>
|
||||
<div className="lh-insight-list">
|
||||
<div>
|
||||
<span>훈련 편향 점검</span>
|
||||
<b>
|
||||
{topPersona?.total
|
||||
? "주호소 범위를 넓힐 차례"
|
||||
: "첫 회기 이후 표시"}
|
||||
</b>
|
||||
<span>훈련 노출 점검</span>
|
||||
<b>{trainingExposure?.label ?? "판정 근거 부족"}</b>
|
||||
<p>
|
||||
한 대상에 치우치면 다른 주호소 대응력이 늦게 올라옵니다.
|
||||
{trainingExposure?.status === "insufficient"
|
||||
? `종료 회기 ${trainingExposure.completed_sessions}/4회 · 4회부터 최다 노출 비중을 확인합니다.`
|
||||
: `${trainingExposure?.dominant_persona_name ?? "최다 연습 대상"} ${trainingExposure?.dominant_sessions ?? 0}/${trainingExposure?.completed_sessions ?? 0}회 · 최다 노출 ${dominantExposurePercent ?? 0}%`}
|
||||
</p>
|
||||
<p>
|
||||
공정성 평가나 임상진단이 아닌 투명한 훈련 노출 지표입니다.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ const OAUTH_PROVIDER_FAILED_MESSAGE =
|
|||
const OAUTH_UNSUPPORTED_PROVIDER_MESSAGE =
|
||||
"지원하지 않는 로그인 공급자입니다. Google 로그인 버튼으로 다시 시작하세요.";
|
||||
const SAML_NOT_CONFIGURED_MESSAGE =
|
||||
"학교 SSO가 아직 연결되지 않았습니다. 현재는 승인된 Google 계정으로 로그인하세요.";
|
||||
"학교 SSO가 아직 연결되지 않았습니다. 현재는 Google 계정으로 로그인하세요.";
|
||||
const SAML_FAILED_MESSAGE =
|
||||
"학교 SSO 로그인 흐름을 완료하지 못했습니다. 관리자에게 SSO 설정 확인을 요청하세요.";
|
||||
|
||||
|
|
@ -131,21 +131,16 @@ export default function Login() {
|
|||
!isLocalRedirectUri(authConfig.redirect_uri);
|
||||
const oauthReady =
|
||||
authConfig?.google_oauth_configured === true && !devOAuthUnavailable;
|
||||
const allowedDomains = authConfig?.allowed_email_domains ?? [];
|
||||
const primaryDomainLabel = devOAuthUnavailable
|
||||
const primaryAccountLabel = devOAuthUnavailable
|
||||
? "로컬은 테스트 계정 사용"
|
||||
: allowedDomains[0]
|
||||
? `@${allowedDomains[0]}`
|
||||
: oauthChecking
|
||||
? "도메인 확인 중"
|
||||
: "승인 도메인 계정";
|
||||
const secondaryDomainLabel = devOAuthUnavailable
|
||||
: "모든 Google 계정";
|
||||
const secondaryAccountLabel = devOAuthUnavailable
|
||||
? "공개 주소에서 사용"
|
||||
: allowedDomains[1]
|
||||
? `@${allowedDomains[1]}`
|
||||
: allowedDomains[0]
|
||||
? "승인된 Google 계정"
|
||||
: "관리자 설정 필요";
|
||||
: oauthChecking
|
||||
? "계정 정책 확인 중"
|
||||
: "이메일 확인 후 바로 시작";
|
||||
const oauthStatusMessage = devOAuthUnavailable
|
||||
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
|
||||
: oauthChecking
|
||||
|
|
@ -183,12 +178,12 @@ export default function Login() {
|
|||
|
||||
return (
|
||||
<AuthShell className="lg-root" variant="room">
|
||||
<LoginBrand allowedDomains={allowedDomains} oauthChecking={oauthChecking} />
|
||||
<LoginBrand oauthChecking={oauthChecking} oauthReady={oauthReady} />
|
||||
<LoginPanel
|
||||
oauth={{
|
||||
ready: oauthReady,
|
||||
primaryDomainLabel,
|
||||
secondaryDomainLabel,
|
||||
primaryAccountLabel,
|
||||
secondaryAccountLabel,
|
||||
statusIcon: authConfigError ? "alert" : "info",
|
||||
statusMessage: oauthStatusMessage,
|
||||
onStart: startOAuth,
|
||||
|
|
|
|||
|
|
@ -1381,6 +1381,17 @@ export default function Session() {
|
|||
} catch (err) {
|
||||
if (err instanceof ApiError && err.detail === "consent_required") {
|
||||
setStartError("개인정보 및 연습 기록 처리 동의 후 회기를 시작할 수 있습니다.");
|
||||
} else if (err instanceof ApiError && err.status === 404) {
|
||||
setStartError(
|
||||
"이 페르소나는 아직 승인되지 않았거나 공개 목록에서 제외됐습니다. 승인 상태를 확인한 뒤 목록에서 다시 선택해 주세요.",
|
||||
);
|
||||
} else if (
|
||||
err instanceof ApiError &&
|
||||
err.detail === "session_persistence_unavailable"
|
||||
) {
|
||||
setStartError(
|
||||
"세션 기록 저장소에 연결할 수 없습니다. 기록 유실을 막기 위해 시작하지 않았습니다. 잠시 뒤 다시 시도해 주세요.",
|
||||
);
|
||||
} else {
|
||||
setStartError("세션을 열지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
|
||||
}
|
||||
|
|
@ -1621,11 +1632,13 @@ export default function Session() {
|
|||
setAvatarState("idle");
|
||||
return;
|
||||
}
|
||||
const normalized = detail.includes("Not logged in")
|
||||
? "AI 엔진 로그인이 필요합니다. 관리자에게 엔진 상태 확인을 요청하세요."
|
||||
: detail.includes("engine unavailable")
|
||||
? "AI 엔진이 응답하지 않습니다. 잠시 뒤 다시 시도하거나 관리자에게 알려 주세요."
|
||||
: "내담자 응답을 생성하지 못했습니다. 잠시 뒤 다시 시도해 주세요.";
|
||||
const normalized = detail.includes("client_stream_incomplete")
|
||||
? "내담자 응답 연결이 중간에 끊겼습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요."
|
||||
: detail.includes("Not logged in")
|
||||
? "AI 엔진 로그인이 필요합니다. 입력 내용은 복원했습니다. 관리자에게 엔진 상태 확인을 요청하세요."
|
||||
: detail.includes("engine unavailable") || detail.includes("engine stream")
|
||||
? "AI 엔진이 응답하지 않습니다. 입력 내용은 복원했습니다. 잠시 뒤 다시 시도하고, 반복되면 관리자에게 내담자 응답 엔진 상태 확인을 요청하세요."
|
||||
: "내담자 응답을 생성하지 못했습니다. 입력 내용은 복원했습니다. 같은 발화로 다시 시도해 주세요.";
|
||||
setTurnError(normalized);
|
||||
pushSignal("warn", "AI 엔진 연결 실패");
|
||||
setAvatarState("listening");
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
type UserPrepostMeasuresResponse,
|
||||
} from "../lib/api";
|
||||
import { canAccessRole, useAuth } from "../lib/auth";
|
||||
import { displayPiiSafeText } from "../lib/piiDisplay";
|
||||
import {
|
||||
parsePracticeLaunchIntent,
|
||||
practiceCriterionLabel,
|
||||
|
|
@ -1019,17 +1020,16 @@ export default function SessionReview() {
|
|||
function jumpToTurn(id: string) {
|
||||
if (!data) return;
|
||||
const turns = data.turns ?? [];
|
||||
setActiveTurn(id);
|
||||
const target = turns.find((turn) => turn.id === id || turn.turn_id === id);
|
||||
const displayId = target?.id ?? id;
|
||||
setActiveTurn(displayId);
|
||||
setReviewTab("transcript");
|
||||
if (
|
||||
learnerOnly &&
|
||||
turns.find((turn) => turn.id === id)?.speaker === "client"
|
||||
) {
|
||||
if (learnerOnly && target?.speaker === "client") {
|
||||
setLearnerOnly(false);
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
turnRefs.current[id]?.scrollIntoView({
|
||||
turnRefs.current[displayId]?.scrollIntoView({
|
||||
behavior: "smooth",
|
||||
block: "center",
|
||||
});
|
||||
|
|
@ -1211,19 +1211,26 @@ export default function SessionReview() {
|
|||
}
|
||||
|
||||
const turns = data.turns ?? [];
|
||||
const sessionNo =
|
||||
(data as SessionReviewResponse & { sessionNo?: number }).sessionNo ?? 1;
|
||||
const phases = data.phases ?? [];
|
||||
const phaseAxis = data.phaseAxis ?? [];
|
||||
const valenceAxis = data.valenceAxis ?? [];
|
||||
const clientValence = data.clientValence ?? [];
|
||||
const counselorBaseline = data.counselorBaseline ?? [];
|
||||
const rubric = data.rubric ?? [];
|
||||
const firstSessionChecklist = data.firstSessionChecklist;
|
||||
const goodMoments = data.goodMoments ?? [];
|
||||
const growthPoints = data.growthPoints ?? [];
|
||||
const hasValence = clientValence.length > 0 || counselorBaseline.length > 0;
|
||||
const canOpenAudio = Boolean(data.audioUrl);
|
||||
const canExportPdf = Boolean(data.pdfExportUrl);
|
||||
const hasTranscript = turns.length > 0;
|
||||
const canCreateShare = !isSupervisorView && data.sessionSignal === "종료됨";
|
||||
const learnerFeedbackVisible = isSupervisorView || data.learnerFeedbackEnabled;
|
||||
const canCreateShare =
|
||||
!isSupervisorView &&
|
||||
data.learnerFeedbackEnabled &&
|
||||
data.sessionSignal === "종료됨";
|
||||
const teacherReview = data.teacherReview;
|
||||
const teacherReviewStatus = teacherReview?.status ?? "pending";
|
||||
const teacherReviewStatusLabel =
|
||||
|
|
@ -1464,7 +1471,7 @@ export default function SessionReview() {
|
|||
>
|
||||
PDF 내보내기
|
||||
</Button>
|
||||
{!isSupervisorView ? (
|
||||
{!isSupervisorView && data.learnerFeedbackEnabled ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
|
|
@ -1509,6 +1516,7 @@ export default function SessionReview() {
|
|||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
isSupervisorView={isSupervisorView}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
|
|
@ -1559,9 +1567,10 @@ export default function SessionReview() {
|
|||
sessionId={data.session_id}
|
||||
isSupervisorView={false}
|
||||
practiceLaunchIntent={returnedTransferIntent}
|
||||
feedbackEnabled={data.learnerFeedbackEnabled}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" && returnedDeliberateIntent ? (
|
||||
{data.sessionSignal === "종료됨" && returnedDeliberateIntent && learnerFeedbackVisible ? (
|
||||
<DeliberatePracticeCard
|
||||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
|
|
@ -1574,6 +1583,7 @@ export default function SessionReview() {
|
|||
<MultimodalAllianceCard
|
||||
sessionId={data.session_id}
|
||||
isSupervisorView={isSupervisorView}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" && !returnedTransferIntent ? (
|
||||
|
|
@ -1581,17 +1591,20 @@ export default function SessionReview() {
|
|||
sessionId={data.session_id}
|
||||
isSupervisorView={isSupervisorView}
|
||||
practiceLaunchIntent={null}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" ? (
|
||||
<OutcomeTrajectoryCard
|
||||
sessionId={data.session_id}
|
||||
sessionNo={sessionNo}
|
||||
turns={turns}
|
||||
isSupervisorView={isSupervisorView}
|
||||
feedbackEnabled={learnerFeedbackVisible}
|
||||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" ? (
|
||||
{data.sessionSignal === "종료됨" && learnerFeedbackVisible ? (
|
||||
<RuptureRepairCard
|
||||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
|
|
@ -1599,7 +1612,7 @@ export default function SessionReview() {
|
|||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
{data.sessionSignal === "종료됨" && !returnedDeliberateIntent ? (
|
||||
{data.sessionSignal === "종료됨" && !returnedDeliberateIntent && learnerFeedbackVisible ? (
|
||||
<DeliberatePracticeCard
|
||||
sessionId={data.session_id}
|
||||
turns={turns}
|
||||
|
|
@ -1608,7 +1621,7 @@ export default function SessionReview() {
|
|||
onJumpToTurn={jumpToTurn}
|
||||
/>
|
||||
) : null}
|
||||
<Card className="sr-card sr-card--chart">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--chart">
|
||||
<Kicker dot={false}>감정 밸런스 타임라인</Kicker>
|
||||
<div style={{ marginTop: "var(--sp-4)" }}>
|
||||
{hasValence ? (
|
||||
|
|
@ -1624,9 +1637,9 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
<Card className="sr-card sr-card--flow">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--flow">
|
||||
<Kicker dot={false}>회기 흐름</Kicker>
|
||||
<div className="sr-phasebar" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{phases.length > 0 ? (
|
||||
|
|
@ -1662,7 +1675,7 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
</div>
|
||||
|
||||
<Card
|
||||
|
|
@ -1934,7 +1947,60 @@ export default function SessionReview() {
|
|||
</Card>
|
||||
) : null}
|
||||
|
||||
<Card className="sr-card sr-card--side sr-card--rubric">
|
||||
{learnerFeedbackVisible && firstSessionChecklist?.applicable ? (
|
||||
<Card className="sr-card sr-card--side sr-card--first-session-checklist">
|
||||
<div className="sr-first-checklist__heading">
|
||||
<Kicker dot={false}>{firstSessionChecklist.title}</Kicker>
|
||||
<span className="sr-first-checklist__version">
|
||||
{firstSessionChecklist.version}
|
||||
</span>
|
||||
</div>
|
||||
<p className="sr-first-checklist__note">
|
||||
{firstSessionChecklist.note}
|
||||
</p>
|
||||
<div className="sr-first-checklist__items">
|
||||
{(firstSessionChecklist.criteria ?? []).map((criterion) => (
|
||||
<section
|
||||
key={criterion.criterionId}
|
||||
className="sr-first-checklist__item"
|
||||
data-criterion-id={criterion.criterionId}
|
||||
>
|
||||
<div className="sr-first-checklist__item-head">
|
||||
<b>{criterion.label}</b>
|
||||
<span
|
||||
className={`sr-first-checklist__status sr-first-checklist__status--${criterion.status}`}
|
||||
>
|
||||
{criterion.status === "met" ? "근거 확인" : "근거 미발견"}
|
||||
</span>
|
||||
</div>
|
||||
<p>{criterion.description}</p>
|
||||
{(criterion.evidenceTurns ?? []).length > 0 ? (
|
||||
<div className="sr-first-checklist__evidence-list">
|
||||
{(criterion.evidenceTurns ?? []).map((evidence) => (
|
||||
<button
|
||||
key={`${criterion.criterionId}-${evidence.turnId}`}
|
||||
type="button"
|
||||
className="sr-first-checklist__evidence"
|
||||
onClick={() => jumpToTurn(evidence.turnId)}
|
||||
aria-label={`${criterion.label} 근거 ${evidence.turnSeq}번 발화로 이동`}
|
||||
>
|
||||
<span>{evidence.turnSeq}번 발화</span>
|
||||
<q>{displayPiiSafeText(evidence.quote)}</q>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="sr-first-checklist__empty">
|
||||
이번 축어록에서 자동으로 연결할 근거 발화를 찾지 못했습니다.
|
||||
</p>
|
||||
)}
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
) : null}
|
||||
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--rubric">
|
||||
<Kicker dot={false}>기법 사용 분포</Kicker>
|
||||
<div className="sr-rubric" style={{ marginTop: "var(--sp-4)" }}>
|
||||
{rubric.length > 0 ? (
|
||||
|
|
@ -1972,9 +2038,9 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
<Card className="sr-card sr-card--side sr-card--good">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--good">
|
||||
<Kicker dot={false}>좋았던 순간</Kicker>
|
||||
<div
|
||||
className="sr-points sr-points--good"
|
||||
|
|
@ -1996,9 +2062,9 @@ export default function SessionReview() {
|
|||
/>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
<Card className="sr-card sr-card--side sr-card--growth">
|
||||
{learnerFeedbackVisible ? <Card className="sr-card sr-card--side sr-card--growth">
|
||||
<Kicker dot={false}>다음 회기 개선점</Kicker>
|
||||
<div
|
||||
className="sr-points sr-points--grow"
|
||||
|
|
@ -2035,11 +2101,11 @@ export default function SessionReview() {
|
|||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</Card>
|
||||
</Card> : null}
|
||||
|
||||
{!isSupervisorView ? <PrepostMeasureCard /> : null}
|
||||
|
||||
<div
|
||||
{learnerFeedbackVisible ? <div
|
||||
className={surfaceClassName(
|
||||
`sr-feedback${data.clientFeedback ? " sr-feedback--filled" : ""}`,
|
||||
{ variant: "inset" },
|
||||
|
|
@ -2068,7 +2134,7 @@ export default function SessionReview() {
|
|||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div> : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -608,7 +608,7 @@
|
|||
|
||||
.vgops-tabs button {
|
||||
flex: 0 0 auto;
|
||||
height: 34px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface);
|
||||
|
|
@ -660,12 +660,13 @@
|
|||
|
||||
.vgops-user-create input,
|
||||
.vgops-user-create select,
|
||||
.vgops-user-create output,
|
||||
.vgops-users-toolbar input,
|
||||
.vgops-user-table input,
|
||||
.vgops-user-table select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
height: 36px;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
|
|
@ -673,6 +674,12 @@
|
|||
padding: 0 10px;
|
||||
font: var(--fs-sm) / 1.3 var(--font-sans);
|
||||
}
|
||||
.vgops-user-create output {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text);
|
||||
}
|
||||
.vgops-user-create .vgops-checkline input {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
|
|
@ -692,7 +699,7 @@
|
|||
|
||||
.vgops-user-create .vg-btn {
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
min-height: 44px;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
padding-inline: 12px;
|
||||
|
|
@ -756,7 +763,7 @@
|
|||
|
||||
.vgops-user-table {
|
||||
width: 100%;
|
||||
min-width: 1440px;
|
||||
min-width: 1580px;
|
||||
table-layout: fixed;
|
||||
border-collapse: separate;
|
||||
border-spacing: 0;
|
||||
|
|
@ -804,11 +811,12 @@
|
|||
.vgops-user-table th:nth-child(2) { width: 140px; }
|
||||
.vgops-user-table th:nth-child(3) { width: 112px; }
|
||||
.vgops-user-table th:nth-child(4) { width: 145px; }
|
||||
.vgops-user-table th:nth-child(5) { width: 180px; }
|
||||
.vgops-user-table th:nth-child(6) { width: 155px; }
|
||||
.vgops-user-table th:nth-child(7) { width: 90px; }
|
||||
.vgops-user-table th:nth-child(8) { width: 160px; }
|
||||
.vgops-user-table th:nth-child(9) { width: 208px; }
|
||||
.vgops-user-table th:nth-child(5) { width: 140px; }
|
||||
.vgops-user-table th:nth-child(6) { width: 180px; }
|
||||
.vgops-user-table th:nth-child(7) { width: 155px; }
|
||||
.vgops-user-table th:nth-child(8) { width: 90px; }
|
||||
.vgops-user-table th:nth-child(9) { width: 160px; }
|
||||
.vgops-user-table th:nth-child(10) { width: 208px; }
|
||||
|
||||
.vgops-user-sort {
|
||||
width: 100%;
|
||||
|
|
@ -1267,6 +1275,196 @@
|
|||
font-size: var(--fs-sm);
|
||||
}
|
||||
|
||||
.vgops-protocols {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(280px, 0.9fr) minmax(0, 1.25fr);
|
||||
align-items: start;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-create,
|
||||
.vgops-protocol-list,
|
||||
.vgops-protocol-form,
|
||||
.vgops-protocol-toolbar,
|
||||
.vgops-protocol-cards,
|
||||
.vgops-protocol-card,
|
||||
.vgops-protocol-card__copy {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-intro,
|
||||
.vgops-protocol-policy {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.vgops-protocol-field-error {
|
||||
color: var(--crit-text);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vgops-protocol-form {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(96px, 0.38fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.vgops-protocol-form label,
|
||||
.vgops-protocol-toolbar label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-form label > span,
|
||||
.vgops-protocol-toolbar label > span {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.vgops-protocol-form input,
|
||||
.vgops-protocol-form select,
|
||||
.vgops-protocol-form textarea,
|
||||
.vgops-protocol-toolbar input,
|
||||
.vgops-protocol-toolbar select {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
min-height: 44px;
|
||||
border: 1px solid var(--border-strong);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
color: var(--text-strong);
|
||||
padding: 0 11px;
|
||||
font: var(--fs-sm) / 1.4 var(--font-sans);
|
||||
}
|
||||
|
||||
.vgops-protocol-form textarea {
|
||||
min-height: 168px;
|
||||
resize: vertical;
|
||||
padding-block: 10px;
|
||||
}
|
||||
|
||||
.vgops-protocol-form input:focus,
|
||||
.vgops-protocol-form select:focus,
|
||||
.vgops-protocol-form textarea:focus,
|
||||
.vgops-protocol-toolbar input:focus,
|
||||
.vgops-protocol-toolbar select:focus {
|
||||
outline: none;
|
||||
border-color: var(--border-focus);
|
||||
box-shadow: 0 0 0 3px var(--focus-ring);
|
||||
}
|
||||
|
||||
.vgops-protocol-form__source,
|
||||
.vgops-protocol-form__content,
|
||||
.vgops-protocol-policy,
|
||||
.vgops-protocol-submit {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.vgops-protocol-check {
|
||||
min-height: 44px;
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
align-self: end;
|
||||
gap: 9px !important;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg-surface-2);
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.vgops-protocol-check input {
|
||||
width: 20px;
|
||||
min-height: 20px;
|
||||
height: 20px;
|
||||
flex: none;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.vgops-protocol-submit,
|
||||
.vgops-protocol-list .vg-btn {
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.vgops-protocol-toolbar {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(112px, 0.38fr) max-content;
|
||||
align-items: end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-error {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-cards {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) max-content;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--hair);
|
||||
border-radius: var(--radius);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
|
||||
.vgops-protocol-card__copy {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card h3,
|
||||
.vgops-protocol-card p {
|
||||
margin: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.vgops-protocol-card h3 {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-body);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.vgops-protocol-card p {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-width: 88px;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__terminal {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
font-weight: 650;
|
||||
}
|
||||
|
||||
.vgops-ticket-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
|
@ -1546,6 +1744,10 @@
|
|||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.vgops-protocols {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vgops-ticket {
|
||||
grid-template-columns: minmax(0, 1fr) 84px;
|
||||
}
|
||||
|
|
@ -1626,6 +1828,30 @@
|
|||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vgops-protocol-form,
|
||||
.vgops-protocol-toolbar,
|
||||
.vgops-protocol-card,
|
||||
.vgops-protocol-error {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.vgops-protocol-form__source,
|
||||
.vgops-protocol-form__content,
|
||||
.vgops-protocol-policy,
|
||||
.vgops-protocol-submit {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.vgops-protocol-card {
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.vgops-protocol-card__actions,
|
||||
.vgops-protocol-card__actions .vg-btn,
|
||||
.vgops-protocol-error .vg-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.vgops-users-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
|
|
|
|||
|
|
@ -288,6 +288,10 @@ export function normalizeAdminUsersResponse(rawResponse: AdminUsersResponse): {
|
|||
warnings,
|
||||
label,
|
||||
),
|
||||
learner_feedback_enabled:
|
||||
typeof sourceRecord.learner_feedback_enabled === "boolean"
|
||||
? sourceRecord.learner_feedback_enabled
|
||||
: true,
|
||||
super_admin: booleanField(
|
||||
sourceRecord,
|
||||
"super_admin",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { Icon } from "../../components/ui/Icon";
|
||||
|
||||
interface LoginBrandProps {
|
||||
allowedDomains: string[];
|
||||
oauthChecking: boolean;
|
||||
oauthReady: boolean;
|
||||
}
|
||||
|
||||
function VignetteMark() {
|
||||
|
|
@ -22,7 +22,7 @@ function VignetteMark() {
|
|||
);
|
||||
}
|
||||
|
||||
export function LoginBrand({ allowedDomains, oauthChecking }: LoginBrandProps) {
|
||||
export function LoginBrand({ oauthChecking, oauthReady }: LoginBrandProps) {
|
||||
return (
|
||||
<section className="lg-brand" aria-label="Vignette">
|
||||
<div className="lg-wordmark">
|
||||
|
|
@ -48,13 +48,9 @@ export function LoginBrand({ allowedDomains, oauthChecking }: LoginBrandProps) {
|
|||
<div className="lg-policy">
|
||||
<span>
|
||||
<Icon name="shield" size={17} />
|
||||
허용 도메인
|
||||
Google 로그인
|
||||
</span>
|
||||
{allowedDomains.length ? (
|
||||
allowedDomains.map((domain) => <b key={domain}>{domain}</b>)
|
||||
) : (
|
||||
<b>{oauthChecking ? "확인 중" : "설정 필요"}</b>
|
||||
)}
|
||||
<b>{oauthChecking ? "확인 중" : oauthReady ? "모든 Google 계정" : "설정 필요"}</b>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ export interface LoginRoleOption {
|
|||
|
||||
interface LoginOAuthView {
|
||||
ready: boolean;
|
||||
primaryDomainLabel: string;
|
||||
secondaryDomainLabel: string;
|
||||
primaryAccountLabel: string;
|
||||
secondaryAccountLabel: string;
|
||||
statusIcon: Extract<IconName, "alert" | "info">;
|
||||
statusMessage: string;
|
||||
onStart: () => void;
|
||||
|
|
@ -136,23 +136,23 @@ export function LoginPanel({ oauth, devAccess, error }: LoginPanelProps) {
|
|||
</span>
|
||||
<h2>로그인</h2>
|
||||
<p className="lg-lead">
|
||||
학교 또는 승인된 Google 계정으로 접속하면 역할과 코호트 권한을 확인합니다.
|
||||
이메일이 확인된 Google 계정이면 도메인이나 사전등록 없이 바로 시작할 수 있습니다.
|
||||
</p>
|
||||
|
||||
<div className="lg-actions">
|
||||
<LoginProviderButton
|
||||
variant="primary"
|
||||
icon="school"
|
||||
label="학교 Google 계정으로 계속"
|
||||
subLabel={oauth.primaryDomainLabel}
|
||||
label="Google 계정으로 계속"
|
||||
subLabel={oauth.primaryAccountLabel}
|
||||
disabled={!oauth.ready}
|
||||
onClick={oauth.onStart}
|
||||
/>
|
||||
<LoginProviderButton
|
||||
variant="secondary"
|
||||
icon="google"
|
||||
label="Google 계정으로 계속"
|
||||
subLabel={oauth.secondaryDomainLabel}
|
||||
label="다른 Google 계정 선택"
|
||||
subLabel={oauth.secondaryAccountLabel}
|
||||
disabled={!oauth.ready}
|
||||
onClick={oauth.onStart}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import "./alliance-pulse.css";
|
|||
|
||||
interface PulseTurn {
|
||||
id: string;
|
||||
turn_id?: string | null;
|
||||
ts: string;
|
||||
speaker: string;
|
||||
who: string;
|
||||
|
|
@ -29,6 +30,7 @@ interface AlliancePulseCardProps {
|
|||
sessionId: string;
|
||||
turns: PulseTurn[];
|
||||
isSupervisorView: boolean;
|
||||
feedbackEnabled: boolean;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}
|
||||
|
||||
|
|
@ -206,7 +208,16 @@ function EvidencePicker({
|
|||
onChange: (turnIds: string[]) => void;
|
||||
scope: "learner" | "supervisor";
|
||||
}) {
|
||||
const eligible = useMemo(() => turns.slice(-12), [turns]);
|
||||
const eligible = useMemo(
|
||||
() =>
|
||||
turns
|
||||
.flatMap((turn) => {
|
||||
const persistedId = turn.turn_id?.trim();
|
||||
return persistedId ? [{ turn, persistedId }] : [];
|
||||
})
|
||||
.slice(-12),
|
||||
[turns],
|
||||
);
|
||||
const summaryLabel =
|
||||
scope === "learner" ? "내 판단의 근거 장면 선택" : "교수자 판정 근거 장면 선택";
|
||||
|
||||
|
|
@ -226,12 +237,12 @@ function EvidencePicker({
|
|||
</summary>
|
||||
{eligible.length > 0 ? (
|
||||
<div className="ap-evidence-picker__list">
|
||||
{eligible.map((turn) => (
|
||||
{eligible.map(({ turn, persistedId }) => (
|
||||
<label key={turn.id}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.includes(turn.id)}
|
||||
onChange={() => toggle(turn.id)}
|
||||
checked={selected.includes(persistedId)}
|
||||
onChange={() => toggle(persistedId)}
|
||||
/>
|
||||
<span className="ap-evidence-picker__time">{turn.ts}</span>
|
||||
<span>
|
||||
|
|
@ -242,7 +253,7 @@ function EvidencePicker({
|
|||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>선택할 수 있는 저장 발화가 없습니다.</p>
|
||||
<p>영속 ID가 확인된 저장 발화가 없습니다.</p>
|
||||
)}
|
||||
</details>
|
||||
);
|
||||
|
|
@ -360,6 +371,25 @@ function LockedWaitingState({ pulse }: { pulse: AlliancePulse }) {
|
|||
);
|
||||
}
|
||||
|
||||
function SelfOnlyState({ pulse }: { pulse: AlliancePulse }) {
|
||||
return (
|
||||
<div className="ap-waiting ap-self-only" aria-label="저장된 치료 동맹 자기평가">
|
||||
<div className="ap-waiting__copy">
|
||||
<b>저장한 내 치료 동맹 자기평가</b>
|
||||
<p>내가 직접 잠근 세 축만 보존합니다. AI·교수자 파생 비교는 표시하지 않습니다.</p>
|
||||
</div>
|
||||
<dl className="ap-locked-scores">
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<div key={dimension}>
|
||||
<dt>{DIMENSION_COPY[dimension].short}</dt>
|
||||
<dd>{scoreText(pulse.self_scores?.[dimension] ?? null)}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ComparisonView({
|
||||
pulse,
|
||||
onJumpToTurn,
|
||||
|
|
@ -525,6 +555,7 @@ export function AlliancePulseCard({
|
|||
sessionId,
|
||||
turns,
|
||||
isSupervisorView,
|
||||
feedbackEnabled,
|
||||
onJumpToTurn,
|
||||
}: AlliancePulseCardProps) {
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
|
|
@ -559,12 +590,16 @@ export function AlliancePulseCard({
|
|||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pulse || (pulse.status !== "awaiting_agents" && pulse.status !== "processing")) {
|
||||
if (
|
||||
!feedbackEnabled ||
|
||||
!pulse ||
|
||||
(pulse.status !== "awaiting_agents" && pulse.status !== "processing")
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const timer = window.setTimeout(() => void load(), POLL_INTERVAL_MS);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [load, pulse]);
|
||||
}, [feedbackEnabled, load, pulse]);
|
||||
|
||||
const submitSelfAssessment = async () => {
|
||||
if (!isCompleteDraft(draft) || submitting) return;
|
||||
|
|
@ -606,8 +641,8 @@ export function AlliancePulseCard({
|
|||
const revealReady = Boolean(
|
||||
pulse?.learner_locked_at && pulse?.revealed_at,
|
||||
);
|
||||
const status = pulse ? pulseStatusCopy(pulse) : null;
|
||||
const learnerEvidenceRequired = turns.length > 0;
|
||||
const status = feedbackEnabled && pulse ? pulseStatusCopy(pulse) : null;
|
||||
const learnerEvidenceRequired = turns.some((turn) => Boolean(turn.turn_id?.trim()));
|
||||
const canSubmit =
|
||||
isCompleteDraft(draft) &&
|
||||
(!learnerEvidenceRequired || evidenceTurnIds.length > 0) &&
|
||||
|
|
@ -642,8 +677,16 @@ export function AlliancePulseCard({
|
|||
content = (
|
||||
<div className="ap-self-form">
|
||||
<div className="ap-self-form__intro">
|
||||
<b>AI 관점을 보기 전에 먼저 스스로 판단합니다</b>
|
||||
<p>세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다.</p>
|
||||
<b>
|
||||
{feedbackEnabled
|
||||
? "AI 관점을 보기 전에 먼저 스스로 판단합니다"
|
||||
: "목표·과업·유대를 내가 직접 기록합니다"}
|
||||
</b>
|
||||
<p>
|
||||
{feedbackEnabled
|
||||
? "세 축을 따로 평가하세요. 제출하면 값은 잠기며 이후 관점에 맞춰 수정할 수 없습니다."
|
||||
: "세 축을 따로 평가해 저장합니다. AI·교수자 파생 비교는 표시하지 않습니다."}
|
||||
</p>
|
||||
</div>
|
||||
{DIMENSIONS.map((dimension) => (
|
||||
<AxisAssessmentControl
|
||||
|
|
@ -667,14 +710,22 @@ export function AlliancePulseCard({
|
|||
<div className="ap-self-form__commit">
|
||||
<span>
|
||||
<Icon name="shield" size={15} strokeWidth={1.8} />
|
||||
제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다.
|
||||
{feedbackEnabled
|
||||
? "제출 뒤에는 AI 관점이 준비될 때까지 내 판단만 보입니다."
|
||||
: "제출 뒤에도 내가 직접 잠근 기록만 표시합니다."}
|
||||
</span>
|
||||
<Button size="sm" disabled={!canSubmit} onClick={() => void submitSelfAssessment()}>
|
||||
{submitting ? "판단 잠그는 중" : "내 판단 잠그고 관점 비교"}
|
||||
{submitting
|
||||
? "판단 잠그는 중"
|
||||
: feedbackEnabled
|
||||
? "내 판단 잠그고 관점 비교"
|
||||
: "내 판단 기록"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
} else if (!feedbackEnabled) {
|
||||
content = <SelfOnlyState pulse={pulse} />;
|
||||
} else if (!revealReady) {
|
||||
content = <LockedWaitingState pulse={pulse} />;
|
||||
} else {
|
||||
|
|
@ -697,8 +748,14 @@ export function AlliancePulseCard({
|
|||
<Card className="sr-card ap-card" aria-busy={loadState === "loading"}>
|
||||
<div className="ap-card__head">
|
||||
<div>
|
||||
<Kicker dot={false}>치료 동맹 펄스</Kicker>
|
||||
<h2>목표, 과업, 유대를 따로 봅니다</h2>
|
||||
<Kicker dot={false}>
|
||||
{feedbackEnabled ? "치료 동맹 펄스" : "치료 동맹 자기평가"}
|
||||
</Kicker>
|
||||
<h2>
|
||||
{feedbackEnabled
|
||||
? "목표, 과업, 유대를 따로 봅니다"
|
||||
: "목표, 과업, 유대를 직접 기록합니다"}
|
||||
</h2>
|
||||
</div>
|
||||
{status ? <Badge tone={status.tone}>{status.label}</Badge> : null}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ interface CalibrationTransferCardProps {
|
|||
sessionId: string;
|
||||
isSupervisorView: boolean;
|
||||
practiceLaunchIntent: TransferPracticeLaunchIntent | null;
|
||||
feedbackEnabled: boolean;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "error";
|
||||
|
|
@ -191,7 +192,7 @@ function actualBlockerCopy(blocker: string): string {
|
|||
function actualExecutionCopy(
|
||||
response: ActualTransferExecutionResponse | null,
|
||||
): string | null {
|
||||
if (!response) return null;
|
||||
if (!response?.execution) return null;
|
||||
if (response.execution.status === "passed") return "목표 행동이 관찰됨";
|
||||
if (response.execution.status === "failed") return "목표 행동을 다시 연습해야 함";
|
||||
return "이번 회기만으로 판정하기 어려움";
|
||||
|
|
@ -202,6 +203,7 @@ function ActualTransferExecutionObservation({
|
|||
intent,
|
||||
sourceIsValid,
|
||||
data,
|
||||
showFeedback,
|
||||
ledgerRefreshState,
|
||||
onRecorded,
|
||||
}: {
|
||||
|
|
@ -209,6 +211,7 @@ function ActualTransferExecutionObservation({
|
|||
intent: TransferPracticeLaunchIntent;
|
||||
sourceIsValid: boolean;
|
||||
data: CalibrationTransferReadModelResponse;
|
||||
showFeedback: boolean;
|
||||
ledgerRefreshState: "idle" | "refreshing" | "complete";
|
||||
onRecorded: () => void;
|
||||
}) {
|
||||
|
|
@ -249,9 +252,11 @@ function ActualTransferExecutionObservation({
|
|||
setResult(response);
|
||||
setState("success");
|
||||
setMessage(
|
||||
response.idempotent_replay
|
||||
showFeedback
|
||||
? response.idempotent_replay
|
||||
? "이미 기록된 같은 회기 근거와 일치해. 중복 기록은 만들지 않았어."
|
||||
: "이번 완료 회기를 실제 전이 근거로 기록했어.",
|
||||
: "이번 완료 회기를 실제 전이 근거로 기록했어."
|
||||
: "이번 완료 회기 연결 입력을 기록했습니다. AI 판정은 표시하지 않습니다.",
|
||||
);
|
||||
onRecorded();
|
||||
} catch (cause) {
|
||||
|
|
@ -275,11 +280,13 @@ function ActualTransferExecutionObservation({
|
|||
}
|
||||
}
|
||||
|
||||
const afterAssessment = result?.assessment ?? currentAssessment;
|
||||
const afterAssessment = showFeedback
|
||||
? result?.assessment ?? currentAssessment
|
||||
: null;
|
||||
const afterStatus = afterAssessment
|
||||
? ACTUAL_STATUS_COPY[afterAssessment.actual_transfer_status]
|
||||
: null;
|
||||
const executionCopy = actualExecutionCopy(result);
|
||||
const executionCopy = showFeedback ? actualExecutionCopy(result) : null;
|
||||
const actionLabel =
|
||||
state === "saving"
|
||||
? "전이 근거 확인 중"
|
||||
|
|
@ -835,6 +842,7 @@ export function CalibrationTransferCard({
|
|||
sessionId,
|
||||
isSupervisorView,
|
||||
practiceLaunchIntent,
|
||||
feedbackEnabled,
|
||||
}: CalibrationTransferCardProps) {
|
||||
const [data, setData] = useState<CalibrationTransferReadModelResponse | null>(null);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
|
|
@ -859,7 +867,7 @@ export function CalibrationTransferCard({
|
|||
setLoadError("");
|
||||
const request = isSupervisorView
|
||||
? calibrationTransferApi.getForTeacherSession(sessionId, controller.signal)
|
||||
: calibrationTransferApi.getForLearner(controller.signal);
|
||||
: calibrationTransferApi.getForLearner(sessionId, controller.signal);
|
||||
void request
|
||||
.then((payload) => {
|
||||
setData(payload);
|
||||
|
|
@ -1057,8 +1065,9 @@ export function CalibrationTransferCard({
|
|||
<ActualTransferExecutionObservation
|
||||
sessionId={sessionId}
|
||||
intent={practiceLaunchIntent}
|
||||
sourceIsValid={returnedTransferSourceIsValid}
|
||||
sourceIsValid={feedbackEnabled ? returnedTransferSourceIsValid : true}
|
||||
data={data}
|
||||
showFeedback={feedbackEnabled}
|
||||
ledgerRefreshState={ledgerRefreshState}
|
||||
onRecorded={() => {
|
||||
setLedgerRefreshState("refreshing");
|
||||
|
|
@ -1068,10 +1077,16 @@ export function CalibrationTransferCard({
|
|||
) : null}
|
||||
<header className="ct-card__head">
|
||||
<div>
|
||||
<Kicker>Calibration mirror & transfer</Kicker>
|
||||
<h2 id="ct-card-title">먼저 예측하고, 잠근 뒤, 근거로 교정하기</h2>
|
||||
<Kicker>{feedbackEnabled ? "Calibration mirror & transfer" : "학습자 자기예측 원장"}</Kicker>
|
||||
<h2 id="ct-card-title">
|
||||
{feedbackEnabled
|
||||
? "먼저 예측하고, 잠근 뒤, 근거로 교정하기"
|
||||
: "먼저 예측하고 잠그는 내 기록만 보존합니다"}
|
||||
</h2>
|
||||
<p>
|
||||
외부평가를 보기 전에 남긴 자기예측만 비교해. 결과를 맞혔는지를 세는 화면이 아니라 내 판단 습관을 더 정확하게 만드는 원장이야.
|
||||
{feedbackEnabled
|
||||
? "외부평가를 보기 전에 남긴 자기예측만 비교해. 결과를 맞혔는지를 세는 화면이 아니라 내 판단 습관을 더 정확하게 만드는 원장이야."
|
||||
: "내 예측 revision과 명시적 잠금은 유지하며, 내부 평가와 교수자·AI 결과는 표시하지 않습니다."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="ct-card__badges" aria-label="평가 범위">
|
||||
|
|
@ -1184,7 +1199,7 @@ export function CalibrationTransferCard({
|
|||
</p>
|
||||
) : null}
|
||||
|
||||
{latestAssessments.length > 0 ? (
|
||||
{feedbackEnabled && latestAssessments.length > 0 ? (
|
||||
<section className="ct-calibration" aria-labelledby="ct-calibration-title">
|
||||
<div className="ct-section-head">
|
||||
<div>
|
||||
|
|
@ -1198,26 +1213,26 @@ export function CalibrationTransferCard({
|
|||
))}
|
||||
</div>
|
||||
</section>
|
||||
) : (
|
||||
) : feedbackEnabled ? (
|
||||
<p className="ct-awaiting" role="status">
|
||||
잠금과 독립 관찰이 쌓이면 역량별 예측 오차·불확실성 구간·과신 또는 과소신 처방이 여기에 나타나.
|
||||
</p>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{transferSuite ? (
|
||||
{feedbackEnabled && transferSuite ? (
|
||||
<TransferCoverage
|
||||
suite={transferSuite}
|
||||
sourceSessionId={sessionId}
|
||||
calibrationAssessments={latestAssessments}
|
||||
isSupervisorView={isSupervisorView}
|
||||
/>
|
||||
) : (
|
||||
) : feedbackEnabled ? (
|
||||
<section className="ct-transfer ct-transfer--empty">
|
||||
<span className="ct-eyebrow">Unseen transfer</span>
|
||||
<h3>새 맥락의 전이 근거를 기다리는 중</h3>
|
||||
<p>상황·관계 스타일·난도·표현군이 달라진 장면에서 확인하기 전에는 숙련을 일반화하지 않아.</p>
|
||||
</section>
|
||||
)}
|
||||
) : null}
|
||||
|
||||
{isSupervisorView ? (
|
||||
<TeacherCorrectionPanel
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { randomUuid } from "../../lib/uuid";
|
|||
interface MultimodalAllianceCardProps {
|
||||
sessionId: string;
|
||||
isSupervisorView: boolean;
|
||||
feedbackEnabled: boolean;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "empty" | "error";
|
||||
|
|
@ -498,7 +499,11 @@ function PrivacyLedger({
|
|||
);
|
||||
}
|
||||
|
||||
export function MultimodalAllianceCard({ sessionId, isSupervisorView }: MultimodalAllianceCardProps) {
|
||||
export function MultimodalAllianceCard({
|
||||
sessionId,
|
||||
isSupervisorView,
|
||||
feedbackEnabled,
|
||||
}: MultimodalAllianceCardProps) {
|
||||
const navigate = useNavigate();
|
||||
const [data, setData] = useState<MultimodalAllianceReadModel | null>(null);
|
||||
const [loadState, setLoadState] = useState<LoadState>("loading");
|
||||
|
|
@ -530,7 +535,8 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
const controller = new AbortController();
|
||||
setLoadState((current) => current === "ready" ? "ready" : "loading");
|
||||
setLoadError("");
|
||||
void multimodalAllianceApi.getMetadata(sessionId, controller.signal)
|
||||
void multimodalAllianceApi
|
||||
.getMetadata(sessionId, controller.signal, feedbackEnabled)
|
||||
.then((payload) => {
|
||||
setData(payload);
|
||||
setLoadState("ready");
|
||||
|
|
@ -546,7 +552,7 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
setLoadError(compactError(error, "멀티모달 동맹 원장을 불러오지 못했습니다."));
|
||||
});
|
||||
return () => controller.abort();
|
||||
}, [reloadSeq, sessionId]);
|
||||
}, [feedbackEnabled, reloadSeq, sessionId]);
|
||||
|
||||
const latestConsent = useMemo(() => data
|
||||
? [...data.consent_snapshots].sort((a, b) => a.sequence_no - b.sequence_no).at(-1) ?? null
|
||||
|
|
@ -624,13 +630,13 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
}
|
||||
|
||||
function saveConsent() {
|
||||
const signature = `${retentionDays}:${retainAudio}`;
|
||||
const signature = `${retentionDays}:${retainAudio}:${feedbackEnabled}`;
|
||||
if (!pendingConsentRef.current || pendingConsentSignatureRef.current !== signature) {
|
||||
pendingConsentRef.current = {
|
||||
submission_id: uuid(),
|
||||
consent_status: "granted",
|
||||
retain_audio: retainAudio,
|
||||
retain_derived_features: true,
|
||||
retain_derived_features: feedbackEnabled,
|
||||
transcript_retained: true,
|
||||
retention_days: retentionDays,
|
||||
policy_version: POLICY_VERSION,
|
||||
|
|
@ -735,9 +741,19 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
<Card className="mma-card">
|
||||
<header className="mma-header">
|
||||
<div>
|
||||
<Kicker dot={false}>멀티모달 동맹 근거</Kicker>
|
||||
<h2>말의 내용과 오디오 시간을 같은 시계에서 봅니다</h2>
|
||||
<p>텍스트와 음성은 먼저 독립 측정하고, 검증된 추가 이득이 있을 때만 보정 융합합니다.</p>
|
||||
<Kicker dot={false}>
|
||||
{feedbackEnabled ? "멀티모달 동맹 근거" : "멀티모달 개인정보 원장"}
|
||||
</Kicker>
|
||||
<h2>
|
||||
{feedbackEnabled
|
||||
? "말의 내용과 오디오 시간을 같은 시계에서 봅니다"
|
||||
: "동의·보존·삭제 범위를 직접 관리합니다"}
|
||||
</h2>
|
||||
<p>
|
||||
{feedbackEnabled
|
||||
? "텍스트와 음성은 먼저 독립 측정하고, 검증된 추가 이득이 있을 때만 보정 융합합니다."
|
||||
: "AI 타임라인·측정·융합 결과는 불러오지 않으며 개인정보 통제 기록만 유지합니다."}
|
||||
</p>
|
||||
</div>
|
||||
<div className="mma-header__badges">
|
||||
<Badge tone="info">교육용 관찰</Badge>
|
||||
|
|
@ -745,6 +761,7 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
</div>
|
||||
</header>
|
||||
|
||||
{feedbackEnabled ? (
|
||||
<section className="mma-clock" aria-labelledby="mma-clock-title">
|
||||
<div className="mma-section-head">
|
||||
<div>
|
||||
|
|
@ -808,7 +825,9 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
</article>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{feedbackEnabled ? (
|
||||
<section className="mma-axis-board" aria-labelledby="mma-axis-title">
|
||||
<div className="mma-section-head">
|
||||
<div>
|
||||
|
|
@ -829,10 +848,11 @@ export function MultimodalAllianceCard({ sessionId, isSupervisorView }: Multimod
|
|||
);
|
||||
})}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<PrivacyLedger sessionId={sessionId} data={data} isSupervisorView={isSupervisorView} rawAudio={rawAudio} rawAudioState={rawAudioState} rawAudioError={rawAudioError} selectedScene={selectedEvent} retentionDays={retentionDays} setRetentionDays={setRetentionDays} retainAudio={retainAudio} setRetainAudio={setRetainAudio} consentAcknowledged={consentAcknowledged} setConsentAcknowledged={setConsentAcknowledged} withdrawalAcknowledged={withdrawalAcknowledged} setWithdrawalAcknowledged={setWithdrawalAcknowledged} deletionScopes={deletionScopes} setDeletionScopes={setDeletionScopes} actionState={actionState} actionMessage={actionMessage} onConsent={saveConsent} onWithdraw={withdrawConsent} onDelete={requestDeletion} />
|
||||
|
||||
{!isSupervisorView ? (
|
||||
{!isSupervisorView && feedbackEnabled ? (
|
||||
<footer className="mma-practice-cta">
|
||||
<div><Icon name="mic" size={19} /><div><strong>다음 회기에서 음성 재연습</strong><p>특정 감정을 흉내 내지 않고, 침묵 뒤 응답과 발화 겹침을 줄이는 상호작용 행동을 연습합니다.</p></div></div>
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -40,12 +40,14 @@ interface TrajectoryTurn {
|
|||
|
||||
interface OutcomeTrajectoryCardProps {
|
||||
sessionId: string;
|
||||
sessionNo: number;
|
||||
turns: TrajectoryTurn[];
|
||||
isSupervisorView: boolean;
|
||||
feedbackEnabled: boolean;
|
||||
onJumpToTurn: (turnId: string) => void;
|
||||
}
|
||||
|
||||
type LoadState = "loading" | "ready" | "empty" | "error";
|
||||
type LoadState = "loading" | "ready" | "empty" | "error" | "input_only";
|
||||
type CheckinState = "idle" | "submitting" | "success" | "error";
|
||||
type NullableAxisValues = Record<OutcomeAxis, number | null>;
|
||||
|
||||
|
|
@ -794,8 +796,10 @@ function timelineKeyDown(
|
|||
|
||||
export function OutcomeTrajectoryCard({
|
||||
sessionId,
|
||||
sessionNo,
|
||||
turns,
|
||||
isSupervisorView,
|
||||
feedbackEnabled,
|
||||
onJumpToTurn,
|
||||
}: OutcomeTrajectoryCardProps) {
|
||||
const titleId = useId();
|
||||
|
|
@ -810,6 +814,12 @@ export function OutcomeTrajectoryCard({
|
|||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
let alive = true;
|
||||
if (!isSupervisorView && !feedbackEnabled) {
|
||||
setData(null);
|
||||
setError(null);
|
||||
setLoadState("input_only");
|
||||
return () => controller.abort();
|
||||
}
|
||||
setLoadState("loading");
|
||||
setError(null);
|
||||
|
||||
|
|
@ -841,7 +851,7 @@ export function OutcomeTrajectoryCard({
|
|||
alive = false;
|
||||
controller.abort();
|
||||
};
|
||||
}, [reloadSeq, sessionId]);
|
||||
}, [feedbackEnabled, isSupervisorView, reloadSeq, sessionId]);
|
||||
|
||||
const latestSessionNo = data ? latestSessionNumber(data) : 1;
|
||||
const activeSessionNo = data
|
||||
|
|
@ -862,6 +872,30 @@ export function OutcomeTrajectoryCard({
|
|||
[activeSessionNo, data],
|
||||
);
|
||||
|
||||
if (loadState === "input_only") {
|
||||
return (
|
||||
<Card className="ot-card" aria-labelledby={titleId}>
|
||||
<header className="ot-card__head">
|
||||
<div>
|
||||
<Kicker dot={false}>학습자 성과 체크인</Kicker>
|
||||
<h2 id={titleId}>내가 관찰한 현재 상태를 직접 기록합니다</h2>
|
||||
<p>
|
||||
AI 궤적과 판정은 표시하지 않으며, 학습자가 입력한 세 축과 근거만
|
||||
원장에 보존합니다.
|
||||
</p>
|
||||
</div>
|
||||
<Badge tone="neutral">학습자 입력만</Badge>
|
||||
</header>
|
||||
<LearnerOutcomeCheckin
|
||||
sessionId={sessionId}
|
||||
sessionNo={sessionNo}
|
||||
observations={[]}
|
||||
turns={turns}
|
||||
onSubmitted={() => undefined}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
if (loadState === "loading") return <LoadingState />;
|
||||
if (loadState === "empty") return <EmptyState isSupervisorView={isSupervisorView} />;
|
||||
if (loadState === "error" || !data) {
|
||||
|
|
|
|||
|
|
@ -76,10 +76,11 @@ export class CalibrationLearnerContextError extends Error {
|
|||
}
|
||||
|
||||
export const calibrationTransferApi = {
|
||||
getForLearner: (signal?: AbortSignal) =>
|
||||
supplementalApi.get<CalibrationTransferReadModelResponse>(learnerPath(), {
|
||||
signal,
|
||||
}),
|
||||
getForLearner: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi.get<CalibrationTransferReadModelResponse>(
|
||||
`${learnerPath()}?session_id=${encodeURIComponent(sessionId)}`,
|
||||
{ signal },
|
||||
),
|
||||
|
||||
getForTeacherSession: async (sessionId: string, signal?: AbortSignal) => {
|
||||
const dashboard = await supplementalApi.get<TeacherDashboardLookup>(
|
||||
|
|
|
|||
|
|
@ -526,6 +526,7 @@
|
|||
.mma-privacy-actions > .vg-btn,
|
||||
.mma-delete-fieldset .vg-btn {
|
||||
justify-self: start;
|
||||
min-height: 44px;
|
||||
}
|
||||
|
||||
.mma-raw-access strong,
|
||||
|
|
|
|||
|
|
@ -365,9 +365,16 @@ export function rawAudioPlaybackUrl(
|
|||
}
|
||||
|
||||
export const multimodalAllianceApi = {
|
||||
getMetadata: (sessionId: string, signal?: AbortSignal) =>
|
||||
getMetadata: (
|
||||
sessionId: string,
|
||||
signal?: AbortSignal,
|
||||
includeDerived = true,
|
||||
) =>
|
||||
supplementalApi
|
||||
.get<WireMetadata>(basePath(sessionId), { signal })
|
||||
.get<WireMetadata>(
|
||||
`${basePath(sessionId)}?include_derived=${includeDerived ? "true" : "false"}`,
|
||||
{ signal },
|
||||
)
|
||||
.then(normalizeMetadata),
|
||||
getRawAudio: (sessionId: string, signal?: AbortSignal) =>
|
||||
supplementalApi
|
||||
|
|
|
|||
|
|
@ -1166,6 +1166,112 @@
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
.sr-first-checklist__heading,
|
||||
.sr-first-checklist__item-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: var(--sp-3);
|
||||
}
|
||||
.sr-first-checklist__version {
|
||||
flex: none;
|
||||
color: var(--text-muted);
|
||||
font-family: var(--font-num);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.sr-first-checklist__note {
|
||||
margin: var(--sp-3) 0 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sr-first-checklist__items {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
margin-top: var(--sp-4);
|
||||
}
|
||||
.sr-first-checklist__item {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding-top: var(--sp-4);
|
||||
border-top: 1px solid var(--border-subtle);
|
||||
}
|
||||
.sr-first-checklist__item:first-child {
|
||||
padding-top: 0;
|
||||
border-top: 0;
|
||||
}
|
||||
.sr-first-checklist__item-head b {
|
||||
color: var(--text-strong);
|
||||
font-size: var(--fs-sm);
|
||||
}
|
||||
.sr-first-checklist__item > p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
line-height: 1.55;
|
||||
}
|
||||
.sr-first-checklist__status {
|
||||
flex: none;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.sr-first-checklist__status--met {
|
||||
border-color: color-mix(in srgb, var(--pos-solid) 32%, var(--border-subtle));
|
||||
color: var(--pos-text);
|
||||
background: color-mix(in srgb, var(--pos-solid) 9%, transparent);
|
||||
}
|
||||
.sr-first-checklist__status--not_observed {
|
||||
color: var(--text-muted);
|
||||
background: var(--bg-surface-2);
|
||||
}
|
||||
.sr-first-checklist__evidence-list {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
}
|
||||
.sr-first-checklist__evidence {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
gap: 10px;
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
padding: 9px 10px;
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-strong);
|
||||
background: var(--bg-surface-2);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
.sr-first-checklist__evidence:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 42%, var(--border-subtle));
|
||||
}
|
||||
.sr-first-checklist__evidence:focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.sr-first-checklist__evidence span {
|
||||
color: var(--accent-text);
|
||||
font-family: var(--font-num);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-first-checklist__evidence q {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--fs-xs);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.sr-first-checklist__empty {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.sr-rubric {
|
||||
display: grid;
|
||||
gap: var(--sp-4);
|
||||
|
|
@ -1529,6 +1635,7 @@
|
|||
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 94%, transparent), color-mix(in srgb, var(--bg-surface) 98%, transparent));
|
||||
}
|
||||
[data-theme="dark"] .sr-card--prepost,
|
||||
[data-theme="dark"] .sr-card--first-session-checklist,
|
||||
[data-theme="dark"] .sr-card--rubric {
|
||||
background: linear-gradient(180deg, color-mix(in srgb, var(--bg-surface-2) 92%, transparent), color-mix(in srgb, var(--bg-surface) 96%, transparent));
|
||||
}
|
||||
|
|
@ -1538,6 +1645,7 @@
|
|||
[data-theme="dark"] .sr-chip-toggle,
|
||||
[data-theme="dark"] .sr-eval-scope,
|
||||
[data-theme="dark"] .sr-ws-item,
|
||||
[data-theme="dark"] .sr-first-checklist__evidence,
|
||||
[data-theme="dark"] .sr-prepost__field input,
|
||||
[data-theme="dark"] .sr-feedback,
|
||||
[data-theme="dark"] .sr-nextline {
|
||||
|
|
|
|||
|
|
@ -2843,7 +2843,7 @@
|
|||
background: var(--bg-surface);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius);
|
||||
min-height: 40px;
|
||||
min-height: 44px;
|
||||
padding: 9px 13px;
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
|
|
@ -4835,6 +4835,26 @@
|
|||
.sx-page--active .sx-review-button {
|
||||
min-width: 116px;
|
||||
}
|
||||
|
||||
/* 390px 이하에서는 코칭/전송 문구가 입력 필드를 122px까지 밀어 placeholder가
|
||||
세로로 잘렸다. 접근 가능한 이름은 유지하고 두 액션만 44px 아이콘형으로 압축한다. */
|
||||
.sx-page--active .sx-coach-trigger-btn,
|
||||
.sx-page--active .sx-compose .vg-btn {
|
||||
width: 44px;
|
||||
min-width: 44px;
|
||||
padding-inline: 0;
|
||||
justify-content: center;
|
||||
}
|
||||
.sx-page--active .sx-coach-trigger-btn > span {
|
||||
display: none;
|
||||
}
|
||||
.sx-page--active .sx-compose .vg-btn {
|
||||
font-size: 0;
|
||||
}
|
||||
.sx-page--active .sx-compose textarea {
|
||||
height: 44px;
|
||||
padding-inline: 7px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 프리스타트 아주 좁은 폭: 1180 이하 규칙이 아바타 열을 140px 로 고정해 두는데,
|
||||
|
|
@ -5557,11 +5577,14 @@
|
|||
}
|
||||
|
||||
.sx-page--active .sx-compose {
|
||||
margin-top: 5px;
|
||||
padding-top: 5px;
|
||||
margin-top: 3px;
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.sx-page--active .sx-compose textarea {
|
||||
height: 44px;
|
||||
}
|
||||
|
||||
.sx-page--active .sx-compose textarea,
|
||||
.sx-page--active .sx-compose .vg-btn {
|
||||
height: 44px;
|
||||
}
|
||||
|
|
|
|||
50
data/clinical/crisis-protocol-validation.json
Normal file
50
data/clinical/crisis-protocol-validation.json
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
{
|
||||
"protocol_id": "p1-suicide-ideation-response",
|
||||
"version": "2026-08-27.1",
|
||||
"scope": "P1 가상내담자 자살사고 신호와 상담자 위기반응 훈련",
|
||||
"technical_status": "verified",
|
||||
"clinical_status": "pending_external_review",
|
||||
"clinical_boundary": "이 파일은 기술 안전 게이트와 검토 순서를 고정한다. 위험도 진단이나 임상 적합성 승인을 대신하지 않는다.",
|
||||
"sources": [
|
||||
{
|
||||
"authority": "SAMHSA",
|
||||
"title": "SAFE-T Suicide Assessment Five-Step Evaluation and Triage",
|
||||
"url": "https://www.samhsa.gov/resource/dbhis/safe-t-pocket-card-suicide-assessment-five-step-evaluation-triage-safe-t-clinicians",
|
||||
"applied_to": ["위험요인", "보호요인", "자살사고 탐색", "위험수준별 개입", "기록과 추후관리"]
|
||||
},
|
||||
{
|
||||
"authority": "NIMH",
|
||||
"title": "Youth Outpatient Brief Suicide Safety Assessment Guide",
|
||||
"url": "https://www.nimh.nih.gov/research/research-conducted-at-nimh/asq-toolkit-materials/youth-outpatient/youth-outpatient-brief-suicide-safety-assessment-guide",
|
||||
"applied_to": ["현재 사고", "계획과 수단 접근", "과거 행동", "보호요인", "긴급 평가", "안전계획"]
|
||||
},
|
||||
{
|
||||
"authority": "대한민국 보건복지부",
|
||||
"title": "자살예방상담전화 109",
|
||||
"url": "https://www.mohw.go.kr/menu.es?mid=a10716040000",
|
||||
"applied_to": ["24시간 위기 연결", "109 안내"]
|
||||
}
|
||||
],
|
||||
"review_sequence": [
|
||||
"비밀보장과 안전 예외를 알린다.",
|
||||
"현재 자살사고의 유무·최근성·빈도·강도를 직접적이고 비판단적으로 확인한다.",
|
||||
"계획·의도·수단 접근과 과거 자살행동을 안전 확보에 필요한 범위에서 확인한다.",
|
||||
"살아갈 이유, 지지자, 도움 요청 가능성 같은 보호요인을 확인한다.",
|
||||
"현재 위험이면 혼자 두지 않고 109·119·응급실·현장 보호자와 즉시 연결한다.",
|
||||
"안전계획과 추후 확인을 기록한다. 안전서약만으로 위험을 낮게 판단하지 않는다."
|
||||
],
|
||||
"technical_gates": [
|
||||
"상담자의 자살사고 직접 질문은 수련생 본인의 실제 위기로 오인하지 않는다.",
|
||||
"수련생 본인의 현재적 1인칭 위기 신호는 엔진 호출 전에 중단하고 109 안전자원을 제공한다.",
|
||||
"가상내담자의 정서적 자살사고 표현은 허용하되 수단·방법 상세는 차단한다.",
|
||||
"가상내담자 자살사고 단계는 3을 넘지 않는다.",
|
||||
"approval.reviewer·approval.organization·approval.reviewed_at·approval.decision 네 필드가 모두 기록되기 전에는 clinical_status를 approved로 바꾸지 않는다."
|
||||
],
|
||||
"approval": {
|
||||
"reviewer": null,
|
||||
"organization": null,
|
||||
"reviewed_at": null,
|
||||
"decision": null,
|
||||
"notes": null
|
||||
}
|
||||
}
|
||||
|
|
@ -2,22 +2,22 @@
|
|||
"source": {
|
||||
"source_id": "official_suicide_risk_guidelines",
|
||||
"title": "자살·자해 위험사정 공식 지침 요약",
|
||||
"citation": "Columbia Lighthouse Project C-SSRS: https://cssrs.columbia.edu/ ; NICE NG225 Self-harm guideline: https://www.nice.org.uk/guidance/ng225",
|
||||
"citation": "SAMHSA SAFE-T: https://www.samhsa.gov/resource/dbhis/safe-t-pocket-card-suicide-assessment-five-step-evaluation-triage-safe-t-clinicians ; NIMH Youth Outpatient BSSA: https://www.nimh.nih.gov/research/research-conducted-at-nimh/asq-toolkit-materials/youth-outpatient/youth-outpatient-brief-suicide-safety-assessment-guide ; 보건복지부 109: https://www.mohw.go.kr/menu.es?mid=a10716040000",
|
||||
"kb_kind": "supervisor_pattern",
|
||||
"license_class": "B",
|
||||
"external_llm_ok": true,
|
||||
"source_type": "official_guideline",
|
||||
"version": "2026-06 reference pack",
|
||||
"version": "2026-08-27 technical preflight",
|
||||
"priority": 95,
|
||||
"note": "공식 지침의 실무 원칙을 라이브 코칭용으로 짧게 환언한 요약이다."
|
||||
"note": "공식 지침의 실무 원칙을 라이브 코칭용으로 짧게 환언한 기술 사전검토본이다. 외부 임상팀 승인 전에는 임상 확정본이 아니다."
|
||||
},
|
||||
"chunks": [
|
||||
{
|
||||
"id": "risk-ideation-plan-intent-behavior",
|
||||
"heading": "사고·계획·의도·행동 구분",
|
||||
"kb_kind": "supervisor_pattern",
|
||||
"citation": "Columbia Lighthouse Project C-SSRS official materials: https://cssrs.columbia.edu/",
|
||||
"summary": "자살 위험 단서가 나오면 생각의 최근성·빈도·강도, 계획 여부, 의도, 과거 행동, 접근 가능한 수단, 보호요인을 차분히 구분해 확인한다. 세부 방법을 자세히 묘사하게 만들기보다 안전 확보에 필요한 범위에서 묻는다.",
|
||||
"citation": "SAMHSA SAFE-T: https://www.samhsa.gov/resource/dbhis/safe-t-pocket-card-suicide-assessment-five-step-evaluation-triage-safe-t-clinicians ; NIMH Youth Outpatient BSSA: https://www.nimh.nih.gov/research/research-conducted-at-nimh/asq-toolkit-materials/youth-outpatient/youth-outpatient-brief-suicide-safety-assessment-guide",
|
||||
"summary": "자살 위험 단서가 나오면 현재 사고의 유무와 최근성·빈도·강도를 직접적이고 비판단적으로 묻고, 계획·의도·과거 행동·접근 가능한 수단·살아갈 이유와 지지체계를 구분해 확인한다. 방법을 자세히 재현하게 하기보다 안전 확보에 필요한 범위에서 탐색하고 위험수준별 개입과 추후 확인을 기록한다.",
|
||||
"keywords": ["자살", "죽고", "자해", "위험", "계획", "의도", "시도", "수단", "보호요인", "최근", "빈도", "강도"]
|
||||
},
|
||||
{
|
||||
|
|
@ -29,11 +29,20 @@
|
|||
"keywords": ["자해", "심리사회", "수치심", "판단", "안전", "지지", "반복", "보호체계", "평가"]
|
||||
},
|
||||
{
|
||||
"id": "safety-plan-escalation",
|
||||
"heading": "안전계획과 긴급 연결",
|
||||
"id": "safety-plan-not-contract",
|
||||
"heading": "안전계획은 안전서약을 대신한다",
|
||||
"kb_kind": "microskill",
|
||||
"summary": "위험이 현재적이거나 통제 가능성이 낮으면 회기 목표보다 안전계획과 즉각 연결을 우선한다. 혼자 두지 않기, 보호자·지도자·응급 서비스 연결, 지역 위기전화 안내처럼 실제 행동으로 이어지는 선택지를 제시한다.",
|
||||
"keywords": ["안전계획", "긴급", "응급", "보호자", "기관", "위기전화", "혼자", "연결", "109", "112", "119"]
|
||||
"citation": "NIMH Youth Outpatient BSSA: https://www.nimh.nih.gov/research/research-conducted-at-nimh/asq-toolkit-materials/youth-outpatient/youth-outpatient-brief-suicide-safety-assessment-guide",
|
||||
"summary": "현재 자살사고가 있거나 즉각 위험이 의심되면 회기 목표보다 긴급 안전평가를 우선하고 혼자 두지 않는다. 경고 신호·대처 행동·도움을 요청할 사람·전문기관 연결·수단 접근 제한을 포함한 안전계획을 함께 세우며, 단순 안전서약만으로 위험이 낮아졌다고 판단하지 않는다.",
|
||||
"keywords": ["안전계획", "안전서약", "긴급", "응급", "보호자", "기관", "혼자", "연결", "수단 제한"]
|
||||
},
|
||||
{
|
||||
"id": "korea-109-emergency-connection",
|
||||
"heading": "대한민국 109·119 긴급 연결",
|
||||
"kb_kind": "microskill",
|
||||
"citation": "보건복지부 자살예방상담전화 109: https://www.mohw.go.kr/menu.es?mid=a10716040000",
|
||||
"summary": "대한민국에서 자살 위험 상담은 24시간 자살예방상담전화 109로 연결할 수 있다. 즉각적인 생명 위험이나 응급상황이면 119 또는 가까운 응급실과 현장 보호체계를 우선 연결하고, 대상자를 혼자 두지 않는다.",
|
||||
"keywords": ["109", "119", "자살예방상담전화", "24시간", "응급실", "긴급 연결", "혼자 두지 않기"]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
40
docs/TODO.md
40
docs/TODO.md
|
|
@ -10,6 +10,28 @@
|
|||
|
||||
---
|
||||
|
||||
## 개선관리 워크북 동기화 (2026-08-27)
|
||||
|
||||
> 이 표는 워크북 ID와 현행 구현을 잇는 얇은 추적표다. 내부 기술 DONE의 focused 증거는
|
||||
> `guides/testing.md`, 상세 계약은 SSOT `dev_dashboard.html`이 소유한다. C-001은 기술 사전검증과 외부
|
||||
> 임상 승인을 분리하며, 외부 증거가 없으므로 계속 열린 항목이다.
|
||||
|
||||
| ID | 상태 | 현행 경계 |
|
||||
|---|---|---|
|
||||
| C-001 | **OPEN · 외부 GATE** | 109·수단 상세 차단·ideation 상한·source pack 기술 사전검증은 완료. 임상 검토자 이름·소속 기관·검토일·서면 결정 전부가 있어야 닫는다. F절/B4에서 추적한다. |
|
||||
| C-002 | **DONE · 내부 기술** | 첫 회기 라포 반영·한 초점 열린 질문 체크리스트, 근거 turn 연결, 이후 회기 not-applicable |
|
||||
| C-003 | **DONE · 내부 기술** | 종료 회기 4건 미만 insufficient, 이후 최다 페르소나 비중 0.75 이상 훈련 집중 주의; 임상·공정성 판정 아님 |
|
||||
| REQ-001 | **DONE · 내부 기술** | provider가 이메일을 검증한 모든 Google 계정은 도메인·사전등록 없이 learner·approved로 즉시 로그인. 관리자 exact-email 연구참여자 사전등록 create는 pending 고정, 승인은 별도 PATCH |
|
||||
| REQ-002 | **DONE · 내부 기술** | admin protocol draft→active→retired, 라이선스 C/D 외부 LLM 금지, evaluator-only RAG·rollback; migration 17 owner-run/readiness-only |
|
||||
| REQ-003 | **DONE · 내부 기술** | 페르소나 주호소가 JSON 키 순서나 surface 상태보다 우선 |
|
||||
| REQ-004 | **DONE · 내부 기술** | learner 현재 설정 AND 세션 snapshot. OFF는 AI 파생 API 403/UI 무요청·혼합 결과 redaction, 사용자 입력·privacy 보존; teacher/admin 유지 |
|
||||
| REQ-005 | **DONE · 내부 기술** | 새 회기는 승인 페르소나 `persona_id/version` 고정, 시작/상세 계약 노출, durable create 실패 503 |
|
||||
| REQ-006 | **DONE · 내부 기술** | prompt와 구조화 결과의 상담자/내담자 identity를 `[COUNSELOR]`/`[CLIENT]`로 역할 마스킹 |
|
||||
| REQ-007 | **DONE · 내부 기술** | 회기 종료를 평가보다 먼저 영속화해 옛 평가 실패 뒤에도 새 회기 생성·턴 진행 가능 |
|
||||
| REQ-008 | **DONE · 내부 기술** | 기본 엔진+live-client readiness, 구조화 done 없는 SSE EOF는 `client_stream_incomplete`로 실패 |
|
||||
|
||||
---
|
||||
|
||||
## A. 배포·공개 런타임 게이트 [환경]
|
||||
> 출처: `ops/backlog-2026-06-26.md` B2 · 대시보드 운영 탭 · `ops/tailscale-vnet-runtime-2026-06-27.md` · `ops/public-runtime-watchdog.md` · `HANDOFF.md`
|
||||
|
||||
|
|
@ -98,10 +120,14 @@
|
|||
NAS `.env(0600)` 재구성 + api만 재생성), 클로드 예산 한도 재발으로 NAS `ENGINE_MODE=agy_cli` 전환. 잔여 권고:
|
||||
공유기 DHCP 예약(호스트 MAC D8-BB-C1-A2-3C-5D), Docker VM 클럭 동기화(호스트 대비 ~4일 선행),
|
||||
NAS 로컬 agy 이전(릴레이·노트북 의존 제거). 증거: `docs/ops/evidence/nas-relay-recovery-2026-08-18.json`.
|
||||
- [ ] **stable-source task 재등록 + 재부팅 후 watchdog smoke** — 사용자 승인 clean commit의 detached release root에
|
||||
watchdog·로그온 boot task를 함께 재등록하고 action의 commit/tree/script SHA pin, 5분 watchdog
|
||||
`LastTaskResult=0`, 로그온 boot, 실제 Windows 재부팅 후 엔진/API/터널 자동 복구 + public `/turn`을 실측한다.
|
||||
DNS 개통 후 `api-vnet.18ka.net`을 `-AdditionalPublicHealthUrls`로 명시 추가.
|
||||
- [ ] **stable-source task 재등록 + 재부팅 후 watchdog smoke** — 2026-08-27 stale AF_UNIX socket으로
|
||||
Docker Desktop 백엔드와 공개 DB가 중단된 사건을 기존 container·volume 삭제/교체 없이 복구했다. 부모 IPC 폴더는
|
||||
timestamp `.stale` 이름으로 보존했고, 기존 `vignette-dev-db`와 `vignette_recovered_prod_20260807`만 재기동했다.
|
||||
detached-clean `bf5f7352…0092`·tree `c39f917b…c88`에 watchdog·로그온 boot task를 재등록해
|
||||
action root/commit/tree/script SHA pin, 5분 watchdog `LastTaskResult=0`·failcount `0`, local/public health
|
||||
`status=ok·db=true·engine=true` 3/3을 확인했다. 남은 것은 실제 Windows 재부팅 후 엔진/API/터널 자동 복구와
|
||||
authenticated public `/turn` 실측이다. DNS 개통 후 `api-vnet.18ka.net`을
|
||||
`-AdditionalPublicHealthUrls`로 명시 추가한다.
|
||||
- [x] **노트북 로컬 음성 스택 STT 구현** — 2026-08-08 소유자 결정으로 음성 양쪽을 노트북 상주
|
||||
로컬 모델로 쓴다(STT faster-whisper, TTS는 아래 항목대로 MeloTTS). 이전에는 코드에 로컬 STT 경로가 아예 없었고
|
||||
(`voice_stt_provider`가 `openai|deepgram` 둘뿐), interim/final 스트리밍 구현도 Deepgram 전용이었다.
|
||||
|
|
@ -262,7 +288,11 @@
|
|||
|
||||
- [ ] C1 사례개념화 **확정 루브릭 콘텐츠** + AI 추출/채점 calibration (현재 scaffold_only).
|
||||
- [ ] CBT 체인·이론부합 루브릭.
|
||||
- [ ] 위기개입 프로토콜 임상 문안.
|
||||
- [ ] **위기개입 프로토콜 임상 승인** — 기술 사전검토는 완료했다. P1 위기 분류, 실제 수련생 위기 시
|
||||
엔진 전 차단·109 연결, 자살 수단 상세 차단, `ideation_stage <= 3`, 공식 source pack 계약을
|
||||
`data/clinical/crisis-protocol-validation.json`과
|
||||
`docs/ops/clinical-crisis-protocol-review-2026-08-27.md`에 고정했다. 남은 것은 임상 검토자 이름·소속 기관·검토일·
|
||||
서면 결정 네 값의 외부 승인이다. 이 증거 전에는 `clinical_status=approved`나 개선관리 시트 `완료`로 바꾸지 않는다.
|
||||
- [ ] 평가 골든셋 콘텐츠.
|
||||
|
||||
## G. 운영 후속 [구현] (비차단)
|
||||
|
|
|
|||
|
|
@ -300,10 +300,11 @@
|
|||
<section class="pulse">
|
||||
<div class="pulse-main">
|
||||
<span class="eyebrow">project pulse</span>
|
||||
<h1>지금 위치: <em>G0~G6 · G8 증거 DONE</em>. <em>NAS 엔진 복구 · G7 external proof</em>가 남았다.</h1>
|
||||
<p class="pulse-state"><b>현재:</b> 공개 앱과 current 주기 실회기는 GREEN, NAS만 health timeout이다. <b>다음:</b> 승인 relay → NAS 재검증 → G7 mic·human proof.</p>
|
||||
<h1>지금 위치: <em>G0~G6 · G8 증거 DONE</em>. <em>G7 external proof · 재부팅 실증</em>이 남았다.</h1>
|
||||
<p class="pulse-state"><b>현재:</b> 공개 앱·API·DB와 current NAS 실회기는 GREEN이다. <b>다음:</b> 실제 Windows 재부팅 자동복구 → authenticated public 회기 → G7 mic·human proof.</p>
|
||||
<details class="pulse-details">
|
||||
<summary>운영 복구·watchdog·배포 상세 보기</summary>
|
||||
<p class="pulse-state"><b>2026-08-27 Docker stale socket 인시던트·복구:</b> 공개 로그인 화면은 유지됐지만 API가 <code>db=false</code> degraded 뒤 502가 됐다. 원인은 Docker Desktop 4.82.0 백엔드가 이전 비정상 종료에서 남은 0바이트 AF_UNIX reparse socket <code>Docker\run\dockerInference</code>와 <code>docker-secrets-engine\engine.sock</code>을 ERROR 1920으로 제거하지 못해 startup crash-loop한 결함이다. Docker 전체 종료 후 두 부모 IPC 디렉터리를 <code>run.stale-20260827-232006</code>·<code>docker-secrets-engine.stale-20260827-232006</code>으로 이동해 복구 가능하게 보존했고, container·image·volume 생성/삭제/교체 없이 기존 <code>vignette-dev-db</code>와 <code>vignette_recovered_prod_20260807</code>만 시작했다. API는 detached-clean <code>bf5f7352…0092</code>·tree <code>c39f917b…c88</code>에서 routine API-only 복구했고 public health 3/3 <code>status=ok·db=true·engine=true</code>를 확인했다. 로그온·5분 watchdog도 같은 root/commit/tree와 exact boot/watchdog/start SHA에 재등록했으며 watchdog <code>LastTaskResult=0</code>·failcount <code>0</code>이다. 실제 공개 로그인 브라우저는 console error/warning 0이다. 실제 Windows 재부팅과 authenticated public <code>/turn</code>은 아직 별도 gate다.</p>
|
||||
<p class="pulse-state"><b>2026-08-18 엔진 readiness 장애·수정:</b> 06:30~10:05 KST 공개 API가 <code>engine=false</code>로 약 3.5시간 degraded였다. 원인은 (1) <code>reasoning_effort=high</code> 콜드 프로브가 20초 타임아웃을 넘기면 실패가 성공과 같은 TTL(운영 1800초)로 캐시되는 게이트웨이 결함, (2) 공개 엔진 <code>claude -p</code>가 소유자 Claude 구독을 개발 에이전트 작업과 공유해 사용량 한도 소진 시 생성이 실패하는 운영 경계다. 워치독은 기본 <code>/ready</code>의 캐시 200만 보고 엔진을 healthy로 오판해 API만 재시작하는 루프에 빠졌다. 수정: 실패 전용 <code>ENGINE_READY_FAILURE_TTL_SECONDS</code>(기본 30초) 분리 + 프로브 타임아웃 20→45초, 회귀 4건 신설(게이트웨이 62/62·backend 986). detached-clean <code>4771b97c…c880</code>·tree <code>286723de…c1fa</code>로 API/게이트웨이/워치독·부트 task를 재승격·재등록했고 명시 실행 <code>LastTaskResult=0</code>, 공개 health ok·OpenAPI 126·local voice exact를 재확인했다. 1차 승격은 새 worktree에 <code>apps/api/.env</code>(gitignored 비밀값)가 없어 fail-closed로 실패했다 — 승격 절차에 env 이관 단계가 필요함을 기록한다(약 4분 다운타임).</p>
|
||||
<p class="pulse-state"><b>2026-08-12 인시던트(NAS relay 복구 대기):</b> 공개 API는 health ok·db/engine true·OpenAPI 126·auth 401과 local voice를 유지한다. NAS 프리뷰는 앞선 3/3 <code>degraded·db=true·engine=false</code>에서 15:24 KST local health 3회 타임아웃으로 악화됐고 정적 로그인 화면만 계속 보인다. NAS API의 고정 <code>ENGINE_URL=http://192.168.0.223:9100</code> 대상 listener가 사라졌으며 공개 엔진은 별도 loopback <code>127.0.0.1:9099</code>에서 정상이다. G8 clean-head candidate 112/112·실제 origin 112/112와 rollback receipt는 완료 증거로 보존하지만, 현 NAS runtime은 relay를 source/secret/task에 결속해 복구하고 health 3/3·auth·OpenAPI·assets·실제 회기를 다시 통과할 때까지 DEGRADED다. 자동 재시작·포트 노출·배포는 실행하지 않았다. G7은 별도로 명시 동의 mic·동시 high-water·human evidence가 남는다.</p>
|
||||
<p class="pulse-state">2026-08-07 공개 DB는 recovered named volume으로 전환했고 원본 container·dump·volume을 보존했다. 2026-08-12에는 휴면 Gradle daemon의 <code>8001</code> 점유·cloudflared/voice sidecar 중단과 PowerShell 5.1 native stderr 조기 종료 결함을 복구했다. DB mutation 전에 fresh custom dump <code>9cbda31e…0ce5</code>(7,532,625 bytes·TOC 1,492)를 고정했다. 공개 API·cloudflared는 detached-clean <code>a73bcd24…</code>·tree <code>b02b3a1b…</code>로 fresh 교체했고 public health 3/3·db/engine true·OpenAPI 126·local voice exact를 통과했다. 로그온·5분 watchdog 두 task도 같은 root/commit/tree/script SHA에 pin해 명시 실행 결과 0을 확인했다. 공개 runtime receipt는 <code>47388d58…9b08</code>, 무마이크 rehearsal evidence는 voice <code>61af2e98…</code>·runtime <code>c6e8670e…</code>·topology <code>89f1cb86…</code>이며 UUID/email literal 0이다. G7은 배포가 아니라 실제 mic·동시 high-water·human evidence만 남은 external GATE다.</p>
|
||||
|
|
@ -692,6 +693,7 @@
|
|||
<p class="dg-note">211차 복구·재검증(2026-08-18 15:05 자동화): 엔진 구독 한도 리셋 직후 NAS 엔진 릴레이 실제 1회 시작이 4종 SHA pin 그대로 <b>passed</b>(영수증 receipt-actual-20260818.json, PID 65328, ready 실생 통과). NAS→릴레이 TCP가 막혀 있던 것은 Python311 인바운드 허용 규칙이 Public 프로필뿐인데 이더넷이 Private이라서였고, LAN 서브넷 한정 9100 허용 규칙 추가(소유자 UAC 승인) 뒤 NAS origin health <b>3/3 연속 status=ok·db·engine true</b>, auth 401, OpenAPI 126을 실측 — 2026-08-12 이후 처음이다. current GREEN 표기는 actual-origin 실제 회기 turn→review smoke가 남아 보류. 같은 자동화의 직렬 게이트 재실행은 <b>31 passed/14 failed/12 did-not-run</b>로 실패: 파서 수정 후 첫 실행된 breakpoint 실측 스윕이 settings/learner 협폭 결함 91건을 보고했고(아침 실행에선 연쇄 중단으로 미실행), session-mvp·persistence·voice 실패군은 아침 동일 웹 코드 통과에 근거해 스택·데이터 드리프트가 의심된다 — 원인 조사로 이관, 가짜 증거 금지로 게이트 pending 유지. 증거는 <code>docs/ops/evidence/nas-relay-recovery-2026-08-18.json</code>·<code>usecase-tdd-2026-08-18.json</code>이다.</p>
|
||||
<p class="dg-note">212차 핫픽스(2026-08-18 오후): 공개 <code>POST /sessions/{id}/end</code> 503(소유자 실사용 보고)의 원인은 회기 종료의 pinned_fact upsert가 <b>learner 역할 연결</b>로 실행된 것이었다. <code>app.pinned_fact</code> RLS는 learner에 INSERT(소유 케이스)만 허용하고 SELECT/UPDATE는 AI 컨텍스트·admin/instructor 전용이라 <code>SELECT … FOR UPDATE + INSERT … ON CONFLICT DO UPDATE</code> 조합이 충돌 후보 가시성에서 <code>InsufficientPrivilegeError</code>로 실패하고 fallback 차단이 503으로 노출됐다. 공개 DB 롤백 재현으로 단순 INSERT는 통과·ON CONFLICT 조합은 거부·<code>ai_view=evaluator</code> 컨텍스트는 전체 upsert 통과를 실증했고(<code>bf5f7352</code>), pinned_fact 저장만 같은 learner uid의 AI evaluator 뷰 연결로 분리했다. 검증: 관련 62 passed·app <b>924 passed</b>, 공개 8001 승격 후 health ok. 엔진이 건강해 deep 평가가 처음 팩트를 반환한 순간 발화한 잠복 결함으로, 아침 degraded 구간엔 팩트가 비어 이 경로가 실행되지 않았다.</p>
|
||||
<p class="dg-note">213차 복구·전환(2026-08-18 심야): 대청소 사건(node_modules·구 런타임 워크트리·Temp 소실)과 Docker Desktop 사망·whisper 9882 WinNAT 포트 예약·DHCP LAN IP 회수(223→224)가 연쇄 발생했다. 릴레이를 현행 루트 <code>bf5f7352</code>에 같은 4종 핀으로 재기동(r3 영수증)하고 <code>192.168.0.224:9100</code>으로 바인딩(방화벽 규칙 재발행), NAS는 실행 컨테이너 env 전량을 <code>.env(0600)</code>로 재구성해 ENGINE_URL 224 적용 후 api만 재생성했다. 클로드 예산 한도(Reached maximum budget $0.5)가 재발해 NAS ENGINE_MODE를 <code>agy_cli</code>(gemini)로 전환 — 공개는 소유자가 이미 관리자 UI로 agy 전환 완료. 결과: NAS origin health <b>3/3 status=ok·db·engine true</b>, auth 401, OpenAPI 126, <b>actual-origin 실회기 turn→review smoke 통과</b>(agy 턴 2개→종료 200→리뷰 200) — 2026-08-12 이후 처음으로 201차 current GREEN 조건을 모두 충족했다. 회기 종료 503 핫픽스(<code>bf5f7352</code>)도 소유자 실세션 종료 성공으로 운영 검증. 권고: 공유기 DHCP 예약(호스트 MAC), Docker VM 클럭 동기화(호스트 대비 ~4일 선행 — DB 타임스탬프 오염), NAS 로컬 agy 이전. 증거는 <code>docs/ops/evidence/nas-relay-recovery-2026-08-18.json</code>이다.</p>
|
||||
<p class="dg-note">214차 개선관리 워크북 동기화(2026-08-27): <b>C-002·C-003과 REQ-001~008은 내부 기술 구현 DONE</b>이다. 첫 회기 체크리스트와 종료 회기 기반 훈련 노출 지표를 추가했고, 외부 연구참여자 사전등록은 create에서 <code>pending</code> 고정 뒤 별도 PATCH 승인으로 분리했다. 프로토콜 레지스트리는 <code>draft→active→retired</code>, 라이선스 C/D의 외부 LLM 금지, evaluator-only RAG 색인·rollback을 강제하며 migration 17은 owner-run이고 startup은 readiness-only라 runtime DDL을 하지 않는다. 주호소는 surface보다 우선하고, 새 회기는 <code>persona_id/version</code>을 고정한다. 학습자 AI 피드백은 <b>현재 계정 설정 AND 세션 snapshot</b>일 때만 노출하며 OFF에서는 순수 파생 API 403·기존 share 404·혼합 응답 AI 필드 redaction·UI 파생 endpoint별 요청 0회를 적용한다. 축어록·저장 워크시트·outcome/calibration 입력·multimodal 동의/철회/삭제/raw audio/privacy 원장은 보존하고 alliance는 self-scores-only로 보여주며, privacy 삭제 조작면은 44px 이상·가로 overflow 0을 유지한다. teacher/admin 감독 뷰는 유지한다. 옛 회기 평가 실패는 degraded 리뷰로 남아도 새 회기를 막지 않는다. 헬스는 기본 엔진과 live-client를 함께 확인하고 구조화 <code>done</code> 없는 EOF는 <code>client_stream_incomplete</code>다. 상담자/내담자 이름은 prompt와 구조화 결과에서 <code>[COUNSELOR]</code>/<code>[CLIENT]</code>로 역할 마스킹한다. <b>C-001은 내부 안전 사전검증만 완료</b>했으며 임상 검토자 이름·소속 기관·검토일·서면 결정이 없어 외부 GATE로 계속 연다.</p>
|
||||
<div class="dg-principles" aria-label="디자인 생성 가드레일">
|
||||
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
|
||||
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
|
||||
|
|
@ -874,8 +876,8 @@
|
|||
<div class="tg-head"><h3>코드품질 · 로컬 개발 스택</h3><span class="tg-note">리팩토링 연구 + 1-커맨드 dev 스택 (2026-06-26~27). code-quality-research-2026-06-26.md · local-development.md</span></div>
|
||||
<div class="scards">
|
||||
<article class="scard" data-status="doing" data-cat="인프라·스택·RAG" data-owner="0">
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">DB RECOVERY · PUBLIC LIVE</span><span class="scard-mid"><span class="scard-title">공개 계정·회기 복구와 무손실 전환</span><span class="scard-sum">복구 named volume을 공개 55432에 전환했다. 데이터·auth·public health는 정상이고 task 자동복구는 stable-source 재등록을 기다린다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
<div class="scard-body"><div class="kv k-good"><b>공개 전환</b><p>2026-08-07 18:26 KST owner 승인으로 <code>vignette_recovered_prod_20260807</code>을 <code>vignette-dev-db:55432</code>에 연결했다. cutover 집계 users 84·sessions 30·turns 705, Google 계정 16·소유 회기 30, orphan 0이다. 2026-08-09 Docker Desktop 중단 후 동일 컨테이너만 재기동했고, fresh dump <code>f1fd569c…5f64</code>를 보존한 뒤 누락 migration 14만 단일 트랜잭션으로 적용했다. 현재 local/public health는 prod·DB true·engine true·claude_cli, OpenAPI 126, Google OAuth 302다. API/cloudflared와 watchdog·로그온 task는 detached-clean <code>a73bcd24…</code>·tree <code>b02b3a1b…</code>와 exact script SHA에 pin했고 두 task 명시 실행 결과 0·Ready를 확인했다. 실제 Windows 재부팅 smoke만 남아 있다.</p></div><div class="kv"><b>보존·rollback</b><p>전환 직전 DB는 <code>vignette-dev-db-pre-recovery-20260807-182642</code>로 중지 보존했고 exact dump SHA는 <code>6b84d5c8…8af1f</code>다. 원본 volume, post-merge dump <code>d24eaa75…2e32</code>, 최종 recovered dump <code>d7bcc396…68ed</code>, 2026-08-09 fresh dump <code>f1fd569c…5f64</code>, 자동 rollback을 실증한 실패 후보 2개도 삭제하지 않았다.</p></div><div class="kv k-warn"><b>사용자 확인</b><p>보안상 기존 <code>auth_session</code>은 복사하지 않아 모두 Google 재로그인이 필요하다. 계정·회기 원장은 복구돼 있다. 실제 사용자가 재로그인한 뒤 과거 소유 회기 목록이 보이는지 확인할 때까지 이 카드는 안정화 중으로 유지한다. 상세: <code>ops/public-db-recovery-rehearsal-2026-08-07.md</code>.</p></div></div>
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">DB RECOVERY · PUBLIC LIVE</span><span class="scard-mid"><span class="scard-title">공개 계정·회기 복구와 무손실 전환</span><span class="scard-sum">복구 named volume을 공개 55432에 유지한다. 데이터·auth·public health와 stable-source task 자동복구는 정상이며 실제 재부팅 실증만 남았다.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
<div class="scard-body"><div class="kv k-good"><b>공개 전환</b><p>2026-08-07 18:26 KST owner 승인으로 <code>vignette_recovered_prod_20260807</code>을 <code>vignette-dev-db:55432</code>에 연결했다. cutover 집계 users 84·sessions 30·turns 705, Google 계정 16·소유 회기 30, orphan 0이다. 2026-08-27 Docker Desktop stale AF_UNIX socket 사건에서도 container·volume을 교체하지 않고 동일 컨테이너만 재기동했다. 현재 local/public health는 prod·DB true·engine true·claude_cli다. API와 watchdog·로그온 task는 detached-clean <code>bf5f7352…0092</code>·tree <code>c39f917b…c88</code>와 exact script SHA에 pin했고 watchdog 명시 실행 결과 0·Ready·failcount 0을 확인했다. 실제 Windows 재부팅 smoke만 남아 있다.</p></div><div class="kv"><b>보존·rollback</b><p>전환 직전 DB는 <code>vignette-dev-db-pre-recovery-20260807-182642</code>로 중지 보존했고 exact dump SHA는 <code>6b84d5c8…8af1f</code>다. 원본 volume, post-merge dump <code>d24eaa75…2e32</code>, 최종 recovered dump <code>d7bcc396…68ed</code>, 2026-08-09 fresh dump <code>f1fd569c…5f64</code>, 자동 rollback을 실증한 실패 후보 2개도 삭제하지 않았다. 2026-08-27 문제의 IPC 부모 폴더 두 개도 timestamp <code>.stale</code> 이름으로 보존했다.</p></div><div class="kv k-warn"><b>사용자 확인</b><p>보안상 기존 <code>auth_session</code>은 복사하지 않아 모두 Google 재로그인이 필요하다. 계정·회기 원장은 복구돼 있다. 실제 사용자가 재로그인한 뒤 과거 소유 회기 목록이 보이는지 확인할 때까지 이 카드는 안정화 중으로 유지한다. 상세: <code>ops/public-db-recovery-rehearsal-2026-08-07.md</code>.</p></div></div>
|
||||
</article>
|
||||
<article class="scard" data-status="done" data-cat="인프라·스택·RAG" data-owner="0">
|
||||
<button class="scard-head" aria-expanded="false"><span class="chip c-done">DEV STACK · DB 보존 패치</span><span class="scard-mid"><span class="scard-title">로컬 개발 스택 1-커맨드 기동 — <code>scripts/dev-up.ps1</code></span><span class="scard-sum">정지 DB는 start 우선, 신규 PGDATA는 고정 named volume, 일반 dev-up의 DB 삭제 경로는 0.</span></span><span class="caret" aria-hidden="true"></span></button>
|
||||
|
|
@ -1102,7 +1104,7 @@
|
|||
<tbody>
|
||||
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
|
||||
<tr><td>Design SSOT / auth visual</td><td><code>npm run check:design-ssot</code> / <code>npx playwright test e2e/auth-visual.spec.ts --project=chromium-single-run --reporter=line</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --reporter=line</code></td><td>SSOT checker passed; login/onboarding light-dark desktop-mobile 1 passed; 14 core screens × 7 widths visual gate 14 passed.</td></tr>
|
||||
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-18 현재 수집은 <b>1111 tests / 61 files</b>다(유스케이스 16테마 <code>uc-*.spec.ts</code> 239 시나리오 추가). 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
||||
<tr><td>Full Playwright E2E baseline</td><td><code>npm run e2e:parallel</code> / <code>npm run e2e:single-run</code> / <code>npm run e2e:list</code></td><td>2026-08-27 현재 수집은 <b>1136 tests / 62 files</b>다. 이 숫자는 수집량이며 현 작업트리 전체 GREEN과 동일하지 않다. G8 clean-head release gate는 candidate 112/112와 실제 NAS-origin 112/112를 통과했다. 이전 단일 120/120과 2026-07-15의 fixture desktop/mobile 166/166 + DB/engine/provider 직렬 49/49 = 215/215는 범위가 다른 역사 기준선으로 보존한다.</td></tr>
|
||||
<tr><td>Refactor governance P1~P8</td><td><code>ruff check app</code> / <code>pytest -q app</code> / <code>pytest -q engine_gateway</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code> / <code>npm run check:design-ssot</code> / <code>npm run check:dead-code</code> / <code>npm run check:duplication</code> / <code>npm run build</code> / <code>npm audit --audit-level=high</code> / full Playwright</td><td>Backend 400 passed, gateway 29 passed, web gates/build/audit passed, vulnerabilities 0, production duplication 1 clone/15 lines/0.03%, Playwright 215/215 passed. 상세 근거는 <code>ops/refactor-governance-2026-07-15.md</code>.</td></tr>
|
||||
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
|
||||
<tr><td>Outcome & Alliance OS G0</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider apps/api/app/test_measurement_contract.py apps/api/app/test_runtime_schema_ssot.py -q</code> / <code>scripts/check-measurement-ledger.sql</code> / measurement·API contract checks / web typecheck / DB-backed <code>session-persistence</code> focused E2E 3종</td><td>G0 contract/schema 11 passed, 기존 backend 100 passed, auth 39 passed. Python→JSON Schema→TypeScript→PostgreSQL enum·필수필드 계약이 일치하고 8개 deterministic benchmark가 검증됐다. Live PostgreSQL에서 learner/client/evaluator 가시 행 1/1/2, 교차 누수 0, append-only guard 2를 확인했다. 학습자 턴→교수자 대시보드, 워크시트 검수, 종료 deep 평가→durable 리뷰 E2E는 각각 1 passed. G0/AOS-001~004 완료.</td></tr>
|
||||
|
|
@ -1112,8 +1114,10 @@
|
|||
<tr><td>Outcome & Alliance OS G4/G5 caller</td><td><code>pytest test_session_learning_producer.py test_deliberate_practice.py test_deliberate_practice_store.py test_calibration_transfer.py test_calibration_transfer_store.py</code> / <code>scripts/smoke-session-learning-producer.py</code></td><td>focused 100 passed, 전체 API 855 passed. 실제 종료 회기·durable turn 2개·ready 평가에서 production caller가 G4 처방 원장 5종을 각 1개 생성하고 deterministic replay한다. G5는 prediction lock 전 observation 0, lock callback 뒤 failed 독립 관찰과 evaluator model-run 각 1개이며 replay 중복 0이다. learner/teacher projection이 일치하고 mastery/pass/assessment/transfer 자동 승격은 전부 0건이다. 합성 교육 fixture이며 임상·실제 숙달 주장이 아니다.</td></tr>
|
||||
<tr><td>학생 자기주도 전체 루프 UI</td><td><code>npx playwright test e2e/self-directed-learning-loop.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1 --reporter=dot</code> / <code>npx playwright test e2e/alliance-pulse.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=2</code> / <code>npm run typecheck</code> / 고정 캡처 직접 QA</td><td>실제 <code>src</code> 홈 추천→목표 선택→텍스트/SSE 회기→종료·리뷰→G4 처방 키보드 CTA→새 회기 재연습 시작이 desktop/mobile 2/2, typecheck를 통과했다. 페이지 overflow 0, 홈 CTA 44px·4.5:1 이상 대비, typed launch intent와 replay 모드 보존, 내부 criterion/counterevidence/UUID 비노출을 확인했다. 390×844·320×568 활성 회기는 아바타/문구 겹침 0과 축어록/입력창 내부 포함을 통과했다. 320px 리뷰 1~5 척도는 다섯 선택지 44px 이상·내부/페이지 overflow 0이고 전체 desktop/mobile 8/8이다. 모든 미소유 API는 fixture 404이며 실제 로그인/API/DB 증거가 아니다.</td></tr>
|
||||
<tr><td>Contract SSOT aggregate DTO</td><td><code>py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_evaluation_persistence.py app/test_evaluator_model_routing.py app/test_teacher_dashboard.py app/test_rbac_idor.py app/test_session_turn_persistence.py -q</code> / <code>npm run check:api-types</code> / <code>npm run typecheck</code> / <code>npm run build</code></td><td>50 backend passed; learner sessions, session review/worksheet, teacher dashboard, session start/detail DTOs use generated <code>ApiSchema</code> aliases with UI fallback. Stage responses are OpenAPI enum unions, including review phase key/label, reached phase, evaluation summary/trigger responses, and teacher session/growth stage. <code>.github/workflows/api-contract.yml</code> runs <code>npm run check:api-types</code> on API/Web contract changes.</td></tr>
|
||||
<tr><td>개선관리 C-002·C-003 / REQ-001~008</td><td><code>pytest app/ -q</code> / <code>pytest engine_gateway/ -q</code> / 항목별 focused pytest·DB/browser E2E / <code>npm run check:api-types</code> / <code>npm run build</code></td><td><b>내부 기술 DONE.</b> 2026-08-28 최종 API 1002 passed, gateway 68 passed, Ruff·API type check·typecheck·lint·production build PASS. focused: C-002 3, C-003 11, REQ-001 auth 41 + callback access-log redaction 6 + OAuth UI desktop/mobile 4 + route-mock desktop/mobile 2 + 실제 관리자 DB/browser lifecycle 1, REQ-002·005 26(경고 1) + protocol 실제 DB lifecycle 1 + persona 실제 DB/browser lifecycle 1, REQ-003 34, REQ-004 직접 정책 85·관련 route/read-model 163·desktop/mobile 2, REQ-006 24, REQ-007 관련 163·live/session 50·DB E2E 1, REQ-008 health 1 passed. Google OIDC는 provider-verified 이메일을 도메인·사전등록 없이 learner·approved로 허용하고 suspended는 보존한다. 실제 관리자 E2E는 승인 전 403/대기→승인 후 동일 세션 200→피드백 OFF 영속 재조회→비활성화·세션 0을, persona E2E는 작성→승인→catalog→주호소→exact ID/version pin→controlled SSE→DB 2턴과 exact cleanup/설정 원복을 확인했다. 최종 레이아웃 재실행은 시각 게이트 15/15·포커스 106/106·세션 8/8, 실패·skip 0이며 격리 포트와 프로세스를 모두 종료했다. 전체 Playwright 현 작업트리 GREEN 수치로 환산하지 않는다.</td></tr>
|
||||
<tr><td>개선관리 C-001 외부 임상 승인</td><td><code>data/clinical/crisis-protocol-validation.json</code> / <code>docs/ops/clinical-crisis-protocol-review-2026-08-27.md</code></td><td><b>외부 GATE · 미완료.</b> 109 연결, 자살 수단 상세 차단, <code>ideation_stage <= 3</code>, 공식 source pack과 자동 안전 게이트는 기술 사전검증 완료다. 그러나 임상 검토자 이름·소속 기관·검토일·서면 결정 네 값이 모두 없으므로 <code>clinical_status=approved</code>나 개선관리 시트 완료로 바꾸지 않는다.</td></tr>
|
||||
<tr><td>SEO/GEO share cards</td><td><code>pytest app/test_session_share.py app/test_session_turn_persistence.py -q</code> / <code>npm run generate:api-types</code> / <code>npm run typecheck</code></td><td>21 passed; session share creates hashed-token public unfurl payload without raw transcript, revoked token returns 404, OpenAPI generated share DTOs, review screen share button typechecks. Static <code>robots.txt</code>/<code>sitemap.xml</code>/<code>llms.txt</code> added.</td></tr>
|
||||
<tr><td>Backend pytest baseline</td><td><code>pytest -q app</code></td><td>2026-07-31 전체 실행 439 passed. 외부 Starlette 의존성의 <code>python_multipart</code> PendingDeprecationWarning 1건만 존재한다.</td></tr>
|
||||
<tr><td>Backend pytest baseline</td><td><code>pytest -q app</code></td><td>2026-08-28 전체 실행 1002 passed.</td></tr>
|
||||
<tr><td>X2 evaluator routing/cache/cost trend</td><td><code>python -X utf8 -m pytest -p no:cacheprovider app/test_llm_pricing.py app/test_admin_ops.py app/test_usage_report.py engine_gateway/test_provider_registry.py engine_gateway/test_gateway_model.py -q</code> / API type generation/check / web typecheck/build / admin focused E2E·layout visual gate / authenticated public API smoke</td><td>최신 backend 439 passed, gateway 45 passed, desktop/mobile E2E 2 passed, 7폭 focused visual gate 1 passed. Claude CLI SDK 비용 추정값과 Agy/Gemini·Codex·Claude API 공식 참조단가를 분리하고, 기존 0달러 행의 조회 시 보정·단가 미등록 모델의 <code>미산정</code> 표시·예산 합산·리포트 경고까지 고정했다. Claude 토큰은 전체 agent tree와 캐시 입력을 포함한다. 과거 0/0 Claude 442건은 로컬 JSONL의 실제 usage와 유일 일치한 169건만 백필했고, 273건은 <code>token_unmetered_turns</code>로 남겼다. 인증된 공개 관리자 화면은 확인 시점 30일 Claude 183건 중 계량 125·미계량 58건을 표시했다.</td></tr>
|
||||
<tr><td>H3 raw source isolation</td><td><code>python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_live_coach_sources.py -q</code></td><td>38 passed; persona source registration records raw hash-only artifacts separately from sanitized evaluator-only RAG chunks, and <code>rag.index_document()</code> rejects <code>sensitivity=3</code>/raw-marker chunks before DB access.</td></tr>
|
||||
<tr><td>H3 persona itemized authoring UI</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>npm run check:api-types</code> / <code>npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1</code> / <code>npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1</code> / <code>npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1</code></td><td>Typecheck/build/API type drift check passed; teacher PersonaStudio focused 8 passed including structured list payload and labeled prompt preview without raw JSON; visual gate 9 passed with the prompt tab opened; session-layout desktop/mobile 8 passed.</td></tr>
|
||||
|
|
@ -1190,16 +1194,16 @@
|
|||
<tr><td>Actual deployment env</td><td><code>infra/.env</code> owner-secret fill-in</td><td>Remaining external step: deployment target must provide real <code>APP_DB_PASSWORD</code>, OAuth client id/secret, <code>OPENAI_API_KEY</code>, <code>SESSION_SECRET</code>, and production-safe engine/voice flags. Current local stray values such as <code>ENGINE_MODE=claude_cli</code> and prod sample TTS must not be copied.</td></tr>
|
||||
<tr><td>Focused E2E</td><td><code>admin + db-persistence + voice-success</code></td><td>Latest admin spec: 8 passed on chromium desktop, including all-workspace admin navigation, health dashboard, real server-known users, operation tickets, mobile controls, and tablet form containment.</td></tr>
|
||||
<tr><td>Dev dashboard redteam</td><td><code>scripts/test_dev_dashboard_ssot.py</code> / <code>e2e/dev-dashboard.spec.ts</code></td><td>Unit: SSOT checker 4 tests OK + current dashboard check PASS. E2E: local file, <code>127.0.0.1</code>, <code>localhost</code> 외부형 origin, 필터 오순서/빠른 재시도/키보드, details/tabs 접기, hash anchor, 링크·이미지 무결성, desktop/mobile overflow를 검증해 chromium desktop/mobile 10 passed.</td></tr>
|
||||
<tr><td>Admin manage-users</td><td><code>admin.spec.ts --grep manage real server-known users</code></td><td>desktop/mobile 2 passed</td></tr>
|
||||
<tr><td>Admin manage-users</td><td><code>admin.spec.ts --grep manage real server-known users</code></td><td>route-mock desktop/mobile 2 passed에 더해 2026-08-27 실제 DB/browser 1 passed. pending 계정의 `/personas` 403·`/learn→/pending`, 승인 뒤 동일 세션 200, AI 피드백 OFF PATCH·GET 영속 재조회, 계정 비활성화·active session 0과 격리 포트 종료를 확인했다.</td></tr>
|
||||
<tr><td>Full E2E baseline</td><td><code>PLAYWRIGHT_PORT=5174 npm run e2e</code></td><td>2026-06-27/28 기준선 113 passed</td></tr>
|
||||
<tr><td>Public auth discovery</td><td><code>E2E_PUBLIC_AUTH=1 npx playwright test --list --project=chromium-public-auth</code></td><td>2 tests listed</td></tr>
|
||||
<tr><td>Public pre-auth readiness</td><td><code>public-auth-turn.spec.ts --grep production-safe</code></td><td>production-safe smoke 1 passed. 인증된 public <code>/turn</code> proof는 아직 미완료이며 owner storageState가 필요하다.</td></tr>
|
||||
<tr><td>Public OAuth start</td><td><code>/auth/config</code> + <code>/auth/login?provider=google</code></td><td>2026-06-30 복구 후 auth config 200, Google configured true, redirect URI <code>https://api-vignette.chanpaca.net/auth/callback</code>, dev-login disabled. Google login start는 GET 302와 <code>__Host-vignette_oauth_state</code> HttpOnly/Secure cookie를 반환한다. Authenticated public <code>/turn</code> proof는 owner storageState가 필요하다.</td></tr>
|
||||
<tr><td>Persona auth boundary</td><td><code>GET /personas</code></td><td>2026-06-30 복구 후 public unauth <code>/personas</code>는 401 <code>not authenticated</code>를 반환한다.</td></tr>
|
||||
<tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr>
|
||||
<tr><td>Public runtime scripts · source pin</td><td><code>start/watch/boot-public-runtime*.ps1</code> · <code>install-public-runtime-task.ps1</code> · <code>register-boot-task.ps1</code></td><td>detached-clean <code>a73bcd24…</code>·tree <code>b02b3a1b…</code>와 exact script SHA에 로그온·watchdog 두 task를 재등록했다. 두 task를 명시 실행해 <code>LastTaskResult=0</code>·Ready를 확인했고 working directory도 같은 stable root다. 실제 Windows 재부팅 후 자동복구 smoke만 별도 운영 gate로 남는다.</td></tr>
|
||||
<tr><td>Public API health</td><td><code>http://127.0.0.1:8001/health</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>local/public 모두 <code>status=ok</code>, <code>db=true</code>, <code>engine=true</code>다. 공개 OpenAPI 126과 <code>/admin/voice-runtime</code>, local Whisper/MeloTTS exact provider/model, queue 4를 확인했다. DB container는 recovered named volume과 <code>unless-stopped</code>를 유지한다.</td></tr>
|
||||
<tr><td>Public runtime current snapshot</td><td><code>health + OpenAPI + voice + provenance + Scheduled Tasks</code></td><td>clean source <code>a73bcd24…</code>·tree <code>b02b3a1b…</code>, receipt <code>47388d58…9b08</code> passed. API/cloudflared는 같은 release root에서 실행되고 두 task result 0이다. 무마이크 rehearsal은 voice/runtime/topology를 모두 통과했으며 public health는 실행 뒤에도 ok·db/engine true다.</td></tr>
|
||||
<tr><td>Public runtime scripts · source pin</td><td><code>start/watch/boot-public-runtime*.ps1</code> · <code>install-public-runtime-task.ps1</code> · <code>register-boot-task.ps1</code></td><td>2026-08-27 detached-clean <code>bf5f7352…0092</code>·tree <code>c39f917b…c88</code>와 exact boot <code>4f368b2d…5197</code>·watchdog <code>6cd3c869…8242</code>·start <code>c8c837fd…6cf1</code> SHA에 로그온·watchdog 두 task를 재등록했다. action working directory/root/commit/tree/SHA pin은 PASS, watchdog은 <code>LastTaskResult=0</code>·Ready·failcount 0이다. 부트 task는 현재 healthy surface를 불필요하게 건드리지 않도록 RunNow하지 않았고, 실제 Windows 재부팅 후 자동복구 smoke만 별도 운영 gate로 남는다.</td></tr>
|
||||
<tr><td>Public API health</td><td><code>http://127.0.0.1:8001/health</code> / <code>https://api-vignette.chanpaca.net/health</code></td><td>2026-08-27 복구 후 local은 <code>status=ok·db=true·engine=true</code>, public은 같은 응답을 연속 3/3 반환했다. 실제 공개 로그인 DOM·스크린샷도 정상이며 console error/warning은 0이다. DB container는 기존 recovered named volume을 그대로 유지한다.</td></tr>
|
||||
<tr><td>Public runtime current snapshot</td><td><code>health + provenance + Scheduled Tasks</code></td><td>clean source <code>bf5f7352…0092</code>·tree <code>c39f917b…c88</code>에서 API-only 복구했고 web·engine·cloudflared는 살아 있던 프로세스를 유지했다. 기존 G7 fresh receipt와 무마이크 rehearsal은 역사 증거로 보존하며, 이번 routine 복구를 새 배포·G7 승격으로 오표기하지 않는다.</td></tr>
|
||||
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>https://api-vignette.chanpaca.net/health</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-30 public web login HEAD 200, public API health 200, auth config 200, Google login start 302. Earlier Tailnet checks remain recorded separately; vnet DNS A records are still 0.</td></tr>
|
||||
<tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr>
|
||||
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue