세션 평가와 교수자 분석 보강

This commit is contained in:
Yun Chan 2026-07-01 12:10:52 +09:00
parent 5c4ac04e06
commit fe2796f05a
51 changed files with 4928 additions and 240 deletions

View file

@ -359,8 +359,16 @@ async def _runtime_tables_ready(conn) -> bool:
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'turns'
AND column_name = 'provider_events'
) AS has_turn_provider_events,
AND column_name IN (
'audio_ref',
'silence_ms',
'speech_rate',
'barge_in',
'provider_events'
)
GROUP BY table_schema, table_name
HAVING count(*) = 5
) AS has_turn_voice_metadata_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
@ -485,7 +493,7 @@ async def _runtime_tables_ready(conn) -> bool:
and row["has_engine_config"]
and row["has_session_columns"]
and row["has_state_columns"]
and row["has_turn_provider_events"]
and row["has_turn_voice_metadata_columns"]
and row["has_session_review_worksheet_columns"]
and row["has_stage_defs"]
and row["has_admin_health_event"]
@ -908,6 +916,10 @@ async def ensure_runtime_tables() -> None:
await conn.execute(
"""
ALTER TABLE app.turns
ADD COLUMN IF NOT EXISTS audio_ref TEXT,
ADD COLUMN IF NOT EXISTS silence_ms INT,
ADD COLUMN IF NOT EXISTS speech_rate REAL,
ADD COLUMN IF NOT EXISTS barge_in BOOLEAN,
ADD COLUMN IF NOT EXISTS provider_events JSONB NOT NULL DEFAULT '[]'::jsonb
"""
)

View file

@ -104,6 +104,10 @@ class Settings(BaseSettings):
default=256,
validation_alias="EVALUATOR_SEMANTIC_CACHE_MAX_ENTRIES",
)
session_evaluation_timeout: float = Field(
default=0.0,
validation_alias="SESSION_EVALUATION_TIMEOUT",
)
session_digest_worker_enabled: bool = Field(
default=False,
validation_alias="SESSION_DIGEST_WORKER_ENABLED",

View file

@ -161,8 +161,16 @@ async def healthcheck() -> bool:
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'turns'
AND column_name = 'provider_events'
) AS has_turn_provider_events,
AND column_name IN (
'audio_ref',
'silence_ms',
'speech_rate',
'barge_in',
'provider_events'
)
GROUP BY table_schema, table_name
HAVING count(*) = 5
) AS has_turn_voice_metadata_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
@ -191,7 +199,7 @@ async def healthcheck() -> bool:
and row["has_sessions"]
and row["has_turns"]
and row["has_session_review_status"]
and row["has_turn_provider_events"]
and row["has_turn_voice_metadata_columns"]
and row["has_session_review_worksheet_columns"]
)
except Exception:

View file

@ -17,6 +17,7 @@ in-proc cache/session fallback 은 local dev 에서만 허용한다.
from __future__ import annotations
import logging
from typing import Annotated, Any, Optional
from fastapi import APIRouter, Depends, HTTPException, status
@ -33,6 +34,7 @@ from ..store import InProcSession
from ..store import store
router = APIRouter(prefix="/eval", tags=["eval"])
logger = logging.getLogger(__name__)
# 교수자/관리자만 평가 조회·트리거 (학습자 비노출)
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
@ -52,6 +54,9 @@ class EvaluationSummary(BaseModel):
session_id: str
stage: StageLabel | None = None
status: str | None = None
error: str | None = None
durable: bool = False
deep: Optional[dict[str, Any]] = None
distribution: dict[str, Any] = Field(default_factory=dict)
@ -76,11 +81,14 @@ async def _load_session_or_404(session_id: str, principal: Principal) -> InProcS
def _theory_mode_of(sess) -> Optional[str]:
# 회기에서 학습자가 명시 선택한 이론 모드가 최우선이다.
sess_theory = str(getattr(sess, "theory_mode", "") or "").strip()
if sess_theory:
return sess_theory
tt = getattr(sess.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
# store 가 theory_mode 문자열도 보유(InProcSession.theory_mode)
return getattr(sess, "theory_mode", None)
return None
def _summary_stage(value: object) -> StageLabel | None:
@ -93,6 +101,12 @@ async def eval_health() -> dict[str, str]:
return {"status": "ok", "owner": "features:evaluator", "loops": "fast,deep"}
def _session_evaluation_error_status(error: str) -> int:
if error.startswith("engine_error"):
return status.HTTP_503_SERVICE_UNAVAILABLE
return status.HTTP_502_BAD_GATEWAY
# ════════════════════════════════════════════════════════════════════════════
# 회기 deep-loop 재평가 트리거 (교수자/관리자)
# ════════════════════════════════════════════════════════════════════════════
@ -131,18 +145,35 @@ async def reevaluate_session(
audit_hook=session_persistence.record_llm_call_audit,
)
except EngineError as e:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"engine unavailable: {e}")
if result.error and result.error.startswith("engine_error"):
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=result.error)
await session_persistence.save_session_evaluation(
session_persistence.SessionEvaluationWrite.from_result(
detail = f"engine unavailable: {e}"
write = session_persistence.SessionEvaluationWrite.from_error(
session_id=session_id,
learner_id=sess.learner_id,
result=result,
scope=body.scope if body.scope in ("session_end", "stage_transition") else "session_end",
stage=sess.state.stage.value,
error=detail,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation retry error record did not reach durable store: session_id=%s error=%s",
session_id,
write.error,
)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
write = session_persistence.SessionEvaluationWrite.from_result(
session_id=session_id,
learner_id=sess.learner_id,
result=result,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
detail = "session evaluation retry result was generated but could not be saved"
logger.error("%s: session_id=%s status=%s", detail, session_id, write.status)
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=detail)
if result.error:
raise HTTPException(_session_evaluation_error_status(result.error), detail=result.error)
return result
@ -193,6 +224,7 @@ async def reevaluate_turn(
learner_text_masked=learner.text_masked,
state_after=sess.state, # 조회 시점 상태(정밀 재현은 DB 스냅샷 도입 시)
memory=TurnMemory(recent_turns=recent),
theory_mode=_theory_mode_of(sess),
)
result = await evaluator.evaluate_turn(
@ -219,15 +251,26 @@ async def get_session_evaluation(
아직 평가 트리거가 없었다면 deep=None + 분포.
"""
await _load_session_or_404(session_id, principal)
record, _durable = await session_persistence.load_session_evaluation(session_id, principal)
record, durable = await session_persistence.load_session_evaluation(session_id, principal)
if record is None:
return EvaluationSummary(session_id=session_id, stage=None, deep=None, distribution={})
return EvaluationSummary(
session_id=session_id,
stage=None,
status=None,
error=None,
durable=durable,
deep=None,
distribution={},
)
payload = record.get("payload")
deep = payload if isinstance(payload, dict) else {}
distribution = deep.get("distribution")
return EvaluationSummary(
session_id=session_id,
stage=_summary_stage(record.get("stage") or deep.get("stage")),
status=str(record.get("status") or "") or None,
error=str(record.get("error") or "") or None,
durable=durable,
deep=deep,
distribution=distribution if isinstance(distribution, dict) else {},
)

View file

@ -482,22 +482,20 @@ async def _load_session_or_404(
return sess
async def _load_review_session_or_404(
def _review_supervisor_principal(principal: Principal) -> Principal | None:
if principal.role in {Role.TEACHER, Role.ADMIN}:
return principal
if principal.super_admin:
return principal.with_role(Role.ADMIN)
return None
async def _load_supervisor_review_session_or_404(
session_id: str,
principal: Principal,
*,
include_turn_evaluation: bool = False,
) -> InProcSession:
if principal.role == Role.LEARNER:
return await _load_session_or_404(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=include_turn_evaluation,
)
if principal.role not in {Role.TEACHER, Role.ADMIN}:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied")
sess = await session_persistence.load_session(
session_id,
principal,
@ -511,6 +509,50 @@ async def _load_review_session_or_404(
return sess
async def _load_review_session_or_404(
session_id: str,
principal: Principal,
*,
include_turn_evaluation: bool = False,
) -> tuple[InProcSession, Principal]:
if principal.role == Role.LEARNER:
try:
sess = await _load_session_or_404(
session_id,
principal,
allow_ended=True,
include_turn_evaluation=include_turn_evaluation,
)
return sess, principal
except HTTPException as exc:
supervisor = _review_supervisor_principal(principal)
if supervisor is None or exc.status_code not in {
status.HTTP_403_FORBIDDEN,
status.HTTP_404_NOT_FOUND,
}:
raise
return (
await _load_supervisor_review_session_or_404(
session_id,
supervisor,
include_turn_evaluation=include_turn_evaluation,
),
supervisor,
)
supervisor = _review_supervisor_principal(principal)
if supervisor is None:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied")
return (
await _load_supervisor_review_session_or_404(
session_id,
supervisor,
include_turn_evaluation=include_turn_evaluation,
),
supervisor,
)
async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None:
if await session_persistence.end_session(sess, carry):
sess.ended = True
@ -635,6 +677,7 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
if not sess.turns:
return
timeout_seconds = _session_evaluation_timeout_seconds()
enriched: list[dict[str, object]] = []
for index, turn in enumerate(sess.masked_turns(), start=1):
item: dict[str, object] = dict(turn)
@ -653,31 +696,89 @@ async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
scope="session_end",
audit_hook=session_persistence.record_llm_call_audit,
),
timeout=min(float(settings.engine_timeout), 45.0),
timeout=timeout_seconds,
)
saved = await session_persistence.save_session_evaluation(
session_persistence.SessionEvaluationWrite.from_result(
session_id=sess.session_id,
learner_id=sess.learner_id,
result=result,
write = session_persistence.SessionEvaluationWrite.from_result(
session_id=sess.session_id,
learner_id=sess.learner_id,
result=result,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation save did not reach durable store: session_id=%s status=%s scope=%s",
sess.session_id,
write.status,
write.scope,
)
if saved:
await _enqueue_session_review_ready_notification(sess.session_id)
except asyncio.TimeoutError:
message = f"session evaluation timeout after {timeout_seconds:g}s"
logger.exception("%s: session_id=%s", message, sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
stage=_stage_label(sess.state.stage),
error=message,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation error save did not reach durable store: session_id=%s error=%s",
sess.session_id,
write.error,
)
if saved:
await _enqueue_session_review_ready_notification(sess.session_id)
except Exception as exc:
saved = await session_persistence.save_session_evaluation(
session_persistence.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
stage=_stage_label(sess.state.stage),
error=str(exc),
)
logger.exception("session evaluation failed: session_id=%s", sess.session_id)
write = session_persistence.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=sess.learner_id,
scope="session_end",
stage=_stage_label(sess.state.stage),
error=exc,
)
saved = await session_persistence.save_session_evaluation(write)
if not saved:
logger.error(
"session evaluation failure record did not reach durable store: session_id=%s error=%s",
sess.session_id,
write.error,
)
if saved:
await _enqueue_session_review_ready_notification(sess.session_id)
def _observe_session_evaluation_task(task: asyncio.Task[None], session_id: str) -> None:
try:
task.result()
except asyncio.CancelledError:
logger.warning("session evaluation background task cancelled: session_id=%s", session_id)
except Exception:
logger.exception("session evaluation background task crashed: session_id=%s", session_id)
def _schedule_session_evaluation(sess: InProcSession) -> asyncio.Task[None] | None:
if not sess.turns:
return None
task = asyncio.create_task(
_generate_and_save_session_evaluation(sess),
name=f"session-evaluation:{sess.session_id}",
)
task.add_done_callback(
lambda done, session_id=sess.session_id: _observe_session_evaluation_task(done, session_id)
)
return task
def _session_evaluation_timeout_seconds() -> float:
configured = float(settings.session_evaluation_timeout or settings.engine_timeout)
return max(configured, 1.0)
async def _enqueue_session_review_ready_notification(session_id: str) -> None:
try:
await notifications.enqueue_session_review_ready(session_id=session_id)
@ -685,12 +786,6 @@ async def _enqueue_session_review_ready_notification(session_id: str) -> None:
logger.warning("session review notification enqueue failed: %s", exc)
def _schedule_session_evaluation(sess: InProcSession) -> None:
if not sess.turns:
return
asyncio.create_task(_generate_and_save_session_evaluation(sess))
async def _load_learner_sessions(
principal: Principal,
*,
@ -978,25 +1073,25 @@ async def get_session_review(
principal: CurrentPrincipal,
) -> SessionReviewResponse:
"""Return a role-safe review built only from the stored session transcript."""
sess = await _load_review_session_or_404(
sess, review_principal = await _load_review_session_or_404(
session_id,
principal,
include_turn_evaluation=True,
)
evaluation_record, evaluation_durable = await session_persistence.load_session_evaluation(
session_id,
principal,
review_principal,
)
saved_worksheet_payload, _ = await session_persistence.load_case_worksheet(
session_id,
principal,
review_principal,
)
include_teacher_review = principal.role in {Role.TEACHER, Role.ADMIN}
include_teacher_review = review_principal.role in {Role.TEACHER, Role.ADMIN}
teacher_review_record = None
if include_teacher_review:
teacher_review_record, _ = await session_persistence.load_session_review_status(
session_id,
principal,
review_principal,
)
return build_session_review(
@ -1201,18 +1296,15 @@ async def live_coach_turn(
grounding=grounding,
audit_hook=session_persistence.record_llm_call_audit,
)
try:
await session_persistence.save_live_coach_event(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn_seq=turn_seq,
stage=stage,
learner_text=body.learner_text,
client_reply=body.client_reply,
suggestion=suggestion,
)
except Exception:
pass
await session_persistence.save_live_coach_event(
session_id=sess.session_id,
learner_id=sess.learner_id,
turn_seq=turn_seq,
stage=stage,
learner_text=body.learner_text,
client_reply=body.client_reply,
suggestion=suggestion,
)
return suggestion

View file

@ -12,6 +12,7 @@ from ..deps import Principal, Role, require_role
from ..runtime_policy import require_runtime_fallback_allowed
from ..session_read_model import StageLabel, stage_label
from ..services import session_metrics
from ..stage_contract import STAGE_LABEL_VALUES
from ..store import InProcSession, store
router = APIRouter(prefix="/teacher", tags=["teacher"])
@ -86,6 +87,28 @@ class TeacherLearnerGrowth(BaseModel):
points: list[TeacherGrowthPoint] = Field(default_factory=list)
class TeacherStageBreakdown(BaseModel):
stage: StageLabel
sessions: int = 0
turns: int = 0
class TeacherLearnerAnalysisResponse(BaseModel):
source: str = "in_memory"
learner_id: str
learner_label: str
total_sessions: int
active_sessions: int
ended_sessions: int
pending_reviews: int
closed_reviews: int
summary: TeacherLearnerGrowth
points: list[TeacherGrowthPoint] = Field(default_factory=list)
stage_breakdown: list[TeacherStageBreakdown] = Field(default_factory=list)
sessions: list[TeacherSessionSummary] = Field(default_factory=list)
message: str
class TeacherSafetyAlert(BaseModel):
id: str
session_id: str
@ -134,11 +157,17 @@ def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoi
)
def _build_learner_growth(sessions: list[InProcSession]) -> list[TeacherLearnerGrowth]:
def _build_learner_growth(
sessions: list[InProcSession],
*,
limit: int | None = 12,
point_limit: int | None = 6,
) -> list[TeacherLearnerGrowth]:
metrics = session_metrics.build_learner_growth(
sessions,
learner_label=_learner_label,
limit=12,
limit=limit,
point_limit=point_limit,
)
return [
TeacherLearnerGrowth(
@ -160,6 +189,26 @@ def _build_learner_growth(sessions: list[InProcSession]) -> list[TeacherLearnerG
]
def _stage_breakdown(sessions: list[InProcSession]) -> list[TeacherStageBreakdown]:
buckets = {
stage: {"sessions": 0, "turns": 0}
for stage in STAGE_LABEL_VALUES
}
for sess in sessions:
label = stage_label(sess.state.stage)
bucket = buckets[label]
bucket["sessions"] += 1
bucket["turns"] += len(sess.turns)
return [
TeacherStageBreakdown(
stage=stage,
sessions=buckets[stage]["sessions"],
turns=buckets[stage]["turns"],
)
for stage in STAGE_LABEL_VALUES
]
def _review_status_value(record: dict[str, object] | None) -> Literal["pending", "viewed", "closed"]:
value = str((record or {}).get("status") or "pending")
if value in {"viewed", "closed"}:
@ -274,6 +323,64 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
)
@router.get(
"/learners/{learner_id}/analysis",
response_model=TeacherLearnerAnalysisResponse,
)
async def learner_analysis(
learner_id: str,
principal: TeacherPrincipal,
) -> TeacherLearnerAnalysisResponse:
"""Return one learner's full teacher-visible session timeline and trend."""
sessions, durable = await session_persistence.list_sessions(
principal,
include_turn_evaluation=True,
)
if not durable:
require_runtime_fallback_allowed("teacher learner analysis")
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
learner_sessions = [sess for sess in sessions if sess.learner_id == learner_id]
if not learner_sessions:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="learner not found")
ordered = sorted(
learner_sessions,
key=lambda sess: (sess.created_at, sess.session_no, sess.session_id),
)
review_statuses, _ = await session_persistence.list_session_review_statuses(
[sess.session_id for sess in ordered if sess.ended],
principal,
)
summaries = [
_summary(sess, review_statuses.get(sess.session_id))
for sess in ordered
]
growth = _build_learner_growth(ordered, limit=None, point_limit=None)[0]
pending_reviews = sum(
1
for item in summaries
if item.status == "ended" and item.review_status != "closed"
)
closed_reviews = sum(1 for item in summaries if item.review_status == "closed")
return TeacherLearnerAnalysisResponse(
source="database" if durable else "runtime",
learner_id=learner_id,
learner_label=growth.learner_label,
total_sessions=len(ordered),
active_sessions=sum(1 for sess in ordered if not sess.ended),
ended_sessions=sum(1 for sess in ordered if sess.ended),
pending_reviews=pending_reviews,
closed_reviews=closed_reviews,
summary=growth,
points=growth.points,
stage_breakdown=_stage_breakdown(ordered),
sessions=summaries,
message=f"{growth.learner_label}의 실제 회기 {len(ordered)}건을 오래된 순서로 표시합니다.",
)
@router.put(
"/sessions/{session_id}/review-status",
response_model=TeacherSessionReviewStatusResponse,

View file

@ -44,6 +44,7 @@ _KOREAN_SURNAME_CHARS = (
)
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
_KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}"
_KOREAN_NAME_STOPWORDS = {
"연락",
"연락처",
@ -126,7 +127,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME})"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),
@ -372,6 +373,71 @@ _MEANS_TERMS = [
"약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약",
"손목을", "칼로", "뛰어내리", "수면제 ", "", "방법은", "어떻게 죽",
]
_DISPLAY_PLACEHOLDER_LABELS = {
"NAME": "그 이름",
"ORG": "그 기관",
"PHONE": "연락처",
"EMAIL": "이메일",
"RRN": "식별번호",
"NUMID": "식별번호",
"DATE": "그 날짜",
"MONEY": "그 금액",
"ADDR": "그 주소",
}
_DISPLAY_PLACEHOLDER_RE = re.compile(
r"\[(?P<label>NAME|ORG|PHONE|EMAIL|RRN|NUMID|DATE|MONEY|ADDR)\]"
r"(?P<particle>[은는이가을를와과])?"
)
_DISPLAY_PLACEHOLDER_STREAM_TAIL = 16
def _adjust_particle(value: str, particle: str | None) -> str:
if particle is None:
return value
if particle in ("", ""):
return value + ""
if particle in ("", ""):
return value + ""
if particle in ("", ""):
return value + ""
if particle in ("", ""):
return value + ""
return value + particle
def humanize_pii_placeholders(text: str) -> str:
"""사용자에게 보이는 내담자 응답에서 PII placeholder 토큰을 자연어로 낮춘다."""
if not text:
return text
def _replace(match: re.Match[str]) -> str:
label = match.group("label")
replacement = _DISPLAY_PLACEHOLDER_LABELS.get(label, "그 정보")
return _adjust_particle(replacement, match.group("particle"))
return _DISPLAY_PLACEHOLDER_RE.sub(_replace, text)
class PiiPlaceholderStreamSanitizer:
"""SSE 토큰 경계를 가로질러 나온 PII placeholder를 사용자 표시 전에 치환한다."""
def __init__(self) -> None:
self._tail = ""
def feed(self, chunk: str) -> str:
if not chunk:
return ""
self._tail += chunk
if len(self._tail) <= _DISPLAY_PLACEHOLDER_STREAM_TAIL:
return ""
ready = self._tail[:-_DISPLAY_PLACEHOLDER_STREAM_TAIL]
self._tail = self._tail[-_DISPLAY_PLACEHOLDER_STREAM_TAIL:]
return humanize_pii_placeholders(ready)
def flush(self) -> str:
tail = self._tail
self._tail = ""
return humanize_pii_placeholders(tail)
@dataclass(slots=True)
@ -406,7 +472,7 @@ def sanitize_client_reply(text: str, *, ideation_stage: int) -> OutputGuardResul
blocked = True
return OutputGuardResult(
text=text,
text=humanize_pii_placeholders(text),
blocked=blocked,
needs_regeneration=needs_regen,
reasons=reasons,
@ -440,6 +506,8 @@ __all__ = [
"CrisisResult",
"classify_crisis",
"OutputGuardResult",
"PiiPlaceholderStreamSanitizer",
"humanize_pii_placeholders",
"sanitize_client_reply",
"clamp_ideation",
"crisis_resource",

View file

@ -237,6 +237,7 @@ async def run_turn_generate(
# 5) 출력 가드레일 — 수단 차단 + ideation 상한
guard = guardrail.sanitize_client_reply(reply, ideation_stage=st.ideation_stage)
reply = guard.text
safety_flagged = guard.blocked or (ctx.crisis is not None and ctx.crisis.escalate)
if guard.needs_regeneration:
# 수단정보 누출 → 안전 대체 응답으로 치환(1차). 재생성 루프는 후속.
@ -320,6 +321,7 @@ async def run_turn_stream(
accumulated = ""
flagged = False
stream_meta: dict[str, Any] = {}
display_sanitizer = guardrail.PiiPlaceholderStreamSanitizer()
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
resource = guardrail.crisis_resource()
@ -353,6 +355,9 @@ async def run_turn_stream(
if packet.event == ENGINE_GATEWAY_SSE_ERROR:
payload = packet.payload
detail = payload.detail if isinstance(payload, StreamErrorEvent) else "engine stream error"
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
yield StreamEvent("error", {"detail": detail})
return
if packet.event == ENGINE_GATEWAY_SSE_DONE:
@ -376,7 +381,14 @@ async def run_turn_stream(
accumulated = "…(말을 잇지 못하고 잠시 침묵한다)"
break
yield StreamEvent("token", {"text": text_piece})
display_piece = display_sanitizer.feed(text_piece)
if display_piece:
yield StreamEvent("token", {"text": display_piece})
if not flagged:
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
@ -415,8 +427,14 @@ async def run_turn_stream(
},
)
except EngineGatewaySseDecodeError as e:
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
yield StreamEvent("error", {"detail": str(e)})
except EngineError as e:
display_tail = display_sanitizer.flush()
if display_tail:
yield StreamEvent("token", {"text": display_tail})
yield StreamEvent("error", {"detail": str(e)})

View file

@ -164,6 +164,7 @@ def build_learner_growth(
*,
learner_label: Callable[[str], str],
limit: int | None = None,
point_limit: int | None = 6,
) -> list[LearnerGrowthMetrics]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
@ -222,7 +223,7 @@ def build_learner_growth(
avg_rapport=avg([value for value in rapport_values if value is not None]),
trend=trend,
top_techniques=top_techniques,
points=points[-6:],
points=points if point_limit is None else points[-point_limit:],
)
)
sorted_result = sorted(result, key=lambda item: item.latest_at, reverse=True)

View file

@ -39,6 +39,13 @@ _APPROPRIATENESS_SCORE = {
}
def _coerce_error_message(error: BaseException | str) -> str:
if isinstance(error, BaseException):
message = str(error).strip()
return message or error.__class__.__name__
return str(error).strip() or "unknown session evaluation error"
@dataclass(slots=True)
class CaseContext:
case_id: str
@ -114,7 +121,7 @@ class SessionEvaluationWrite:
scope=scope,
stage=stage,
payload={},
error=str(error),
error=_coerce_error_message(error),
)
def cache_record(self) -> dict[str, Any]:
@ -1254,9 +1261,8 @@ async def load_session_evaluation(
session_id,
)
if row is None:
return (
_EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
), False
cached = _EVALUATION_CACHE.get(session_id) if runtime_fallback_allowed() else None
return cached, cached is None
return {
"status": row["status"],
"source": row["source"],

View file

@ -1201,7 +1201,8 @@ def _review_provider_event(event: dict[str, object]) -> ReviewNonverbalEvent | N
def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
events: list[ReviewNonverbalEvent] = []
if turn.silence_ms is not None and turn.silence_ms >= 1000:
has_turn_level_silence = turn.silence_ms is not None and turn.silence_ms >= 1000
if has_turn_level_silence:
events.append(
ReviewNonverbalEvent(
kind="silence",
@ -1236,6 +1237,8 @@ def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
for event in turn.provider_events:
if not isinstance(event, dict):
continue
if has_turn_level_silence and str(event.get("event_type") or "").strip() == "silence":
continue
review_event = _review_provider_event(event)
if review_event is not None:
events.append(review_event)

View file

@ -0,0 +1,213 @@
"""평가 재시도 라우트의 실패 노출 테스트."""
from __future__ import annotations
import importlib
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .deps import Principal, Role
from .services import evaluator
eval_routes = importlib.import_module("app.routes.eval")
class ReevaluateSessionRouteTest(unittest.IsolatedAsyncioTestCase):
async def test_reevaluate_session_saves_result_error_then_raises(self) -> None:
session_id = "00000000-0000-0000-0000-00000000e222"
sess = _session_stub()
result = evaluator.SessionEvaluation(
session_id=session_id,
stage="정리",
scope="session_end",
turns_evaluated=1,
error="no_structured_output",
)
with (
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(eval_routes.evaluator, "evaluate_session", AsyncMock(return_value=result)),
patch.object(
eval_routes.session_persistence,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
):
with self.assertRaises(HTTPException) as raised:
await eval_routes.reevaluate_session(
session_id,
eval_routes.ReevaluateRequest(scope="session_end"),
_teacher_principal(),
)
self.assertEqual(raised.exception.status_code, 502)
self.assertEqual(raised.exception.detail, "no_structured_output")
saved_write = save_evaluation.await_args.args[0]
self.assertEqual(saved_write.status, "error")
self.assertEqual(saved_write.error, "no_structured_output")
async def test_reevaluate_session_raises_when_result_cannot_be_saved(self) -> None:
session_id = "00000000-0000-0000-0000-00000000e222"
sess = _session_stub()
result = evaluator.SessionEvaluation(
session_id=session_id,
stage="정리",
scope="session_end",
turns_evaluated=1,
)
with (
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(eval_routes.evaluator, "evaluate_session", AsyncMock(return_value=result)),
patch.object(
eval_routes.session_persistence,
"save_session_evaluation",
AsyncMock(return_value=False),
),
):
with self.assertRaises(HTTPException) as raised:
await eval_routes.reevaluate_session(
session_id,
eval_routes.ReevaluateRequest(scope="session_end"),
_teacher_principal(),
)
self.assertEqual(raised.exception.status_code, 503)
self.assertIn("could not be saved", raised.exception.detail)
async def test_reevaluate_session_prefers_session_theory_mode(self) -> None:
session_id = "00000000-0000-0000-0000-00000000e222"
sess = _session_stub(theory_mode="cbt", theory_target=["humanistic"])
result = evaluator.SessionEvaluation(
session_id=session_id,
stage="정리",
scope="session_end",
turns_evaluated=1,
)
with (
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(
eval_routes.evaluator,
"evaluate_session",
AsyncMock(return_value=result),
) as evaluate_session,
patch.object(
eval_routes.session_persistence,
"save_session_evaluation",
AsyncMock(return_value=True),
),
):
await eval_routes.reevaluate_session(
session_id,
eval_routes.ReevaluateRequest(scope="session_end"),
_teacher_principal(),
)
self.assertEqual(evaluate_session.await_args.kwargs["theory_mode"], "cbt")
async def test_reevaluate_turn_preserves_session_theory_mode(self) -> None:
session_id = "00000000-0000-0000-0000-00000000e222"
sess = _session_stub(theory_mode="cbt", theory_target=["humanistic"], with_turns=True)
result = evaluator.TurnEvaluation(turn_seq=1, stage="정리", theory_mode="cbt")
with (
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(
eval_routes.evaluator,
"evaluate_turn",
AsyncMock(return_value=result),
) as evaluate_turn,
):
await eval_routes.reevaluate_turn(
session_id,
eval_routes.TurnReevaluateRequest(turn_seq=1),
_teacher_principal(),
)
ctx = evaluate_turn.await_args.args[0]
self.assertEqual(ctx.theory_mode, "cbt")
async def test_get_session_evaluation_exposes_status_error_and_durable_flag(self) -> None:
session_id = "00000000-0000-0000-0000-00000000e222"
sess = _session_stub()
record = {
"status": "error",
"source": "engine",
"scope": "session_end",
"stage": "정리",
"payload": {},
"error": "session evaluation timeout after 45s",
"updated_at": "2026-07-01T00:00:00Z",
}
with (
patch.object(eval_routes, "_load_session_or_404", AsyncMock(return_value=sess)),
patch.object(
eval_routes.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(record, True)),
),
):
response = await eval_routes.get_session_evaluation(session_id, _teacher_principal())
self.assertEqual(response.status, "error")
self.assertEqual(response.error, "session evaluation timeout after 45s")
self.assertTrue(response.durable)
self.assertEqual(response.deep, {})
def _session_stub(
*,
theory_mode: str = "humanistic",
theory_target: list[str] | None = None,
with_turns: bool = False,
):
selected_theory_mode = theory_mode
selected_theory_target = theory_target or []
class SessionStub:
session_id = "00000000-0000-0000-0000-00000000e222"
case_id = "00000000-0000-0000-0000-00000000c333"
learner_id = "00000000-0000-0000-0000-000000000111"
theory_mode = selected_theory_mode
persona = SimpleNamespace(theory_target=selected_theory_target)
state = SimpleNamespace(stage=SimpleNamespace(value="정리"))
turns = (
[
SimpleNamespace(
turn_seq=1,
speaker="counselor",
text="상담자 발화",
text_masked="상담자 발화",
),
SimpleNamespace(
turn_seq=2,
speaker="client",
text="내담자 응답",
text_masked="내담자 응답",
),
]
if with_turns
else []
)
def masked_turns(self):
return [{"speaker": "counselor", "text_masked": "상담자 발화"}]
return SessionStub()
def _teacher_principal() -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000222",
role=Role.TEACHER,
cohort_ids=[],
)
if __name__ == "__main__":
unittest.main()

View file

@ -175,6 +175,17 @@ class EvaluationPersistenceMappingTest(unittest.TestCase):
self.assertEqual(write.payload, {})
self.assertEqual(write.error, "engine timeout")
def test_session_evaluation_write_from_error_names_empty_exception(self) -> None:
write = session_persistence.SessionEvaluationWrite.from_error(
session_id="session-1",
learner_id="learner-1",
scope="session_end",
stage="explore",
error=TimeoutError(),
)
self.assertEqual(write.error, "TimeoutError")
def test_rebuild_turn_evaluation_restores_review_shape(self) -> None:
rebuilt = session_persistence._rebuild_turn_evaluations(
[("11111111-1111-1111-1111-111111111111", 2, "탐색")],

View file

@ -2,6 +2,7 @@
from __future__ import annotations
import asyncio
import unittest
from contextlib import contextmanager
from typing import Any
@ -181,6 +182,52 @@ class NotificationTriggerTest(unittest.IsolatedAsyncioTestCase):
save_evaluation.assert_awaited_once()
enqueue_review.assert_awaited_once_with(session_id=sess.session_id)
async def test_session_evaluation_timeout_records_actionable_error(self) -> None:
sess = _ended_session()
with (
patched_settings(session_evaluation_timeout=1.0),
patch.object(
sessions.evaluator,
"evaluate_session",
AsyncMock(side_effect=asyncio.TimeoutError()),
),
patch.object(
sessions.session_persistence,
"save_session_evaluation",
AsyncMock(return_value=True),
) as save_evaluation,
patch.object(
sessions.notifications,
"enqueue_session_review_ready",
AsyncMock(),
),
):
await sessions._generate_and_save_session_evaluation(sess)
saved_write = save_evaluation.await_args.args[0]
self.assertEqual(saved_write.status, "error")
self.assertEqual(saved_write.error, "session evaluation timeout after 1s")
async def test_scheduled_session_evaluation_task_crash_is_observed(self) -> None:
sess = _ended_session()
async def boom(_sess: InProcSession) -> None:
raise RuntimeError("background crash")
with patch.object(sessions, "_generate_and_save_session_evaluation", boom):
with self.assertLogs(sessions.logger, level="ERROR") as logs:
task = sessions._schedule_session_evaluation(sess)
self.assertIsNotNone(task)
assert task is not None
with self.assertRaises(RuntimeError):
await task
self.assertTrue(
any("session evaluation background task crashed" in line for line in logs.output),
logs.output,
)
if __name__ == "__main__":
unittest.main()

View file

@ -87,16 +87,17 @@ def _assert_masked_ko_pii_present(test: unittest.TestCase, value: object) -> Non
class CaptureGenerateEngine:
def __init__(self) -> None:
def __init__(self, text: str = "Masked engine reply.") -> None:
self.request = None
self.payload: dict[str, Any] | None = None
self._payload_builder = EngineClient(base_url="http://engine.test")
self.text = text
async def generate(self, req):
self.request = req
self.payload = self._payload_builder._payload(req)
return GenerateResponse(
text="Masked engine reply.",
text=self.text,
model="fake-model",
provider="fake-provider",
tokens_in=3,
@ -109,16 +110,18 @@ class CaptureStreamEngine:
engine_mode = "fake-provider"
default_model = "fake-model"
def __init__(self) -> None:
def __init__(self, chunks: list[str] | None = None) -> None:
self.request = None
self.payload: dict[str, Any] | None = None
self._payload_builder = EngineClient(base_url="http://engine.test")
self.chunks = chunks or ["Masked stream reply."]
async def stream(self, req):
self.request = req
self.payload = self._payload_builder._payload(req)
yield "event: token"
yield 'data: {"text":"Masked stream reply."}'
for chunk in self.chunks:
yield "event: token"
yield "data: " + json.dumps({"text": chunk}, ensure_ascii=False)
yield "event: done"
yield (
'data: {"provider":"fake-provider","model":"fake-model",'
@ -200,6 +203,15 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
self.assertNotIn("NAME", masked.entities)
self.assertNotIn("ORG", masked.entities)
def test_mask_pii_does_not_mask_career_topic_as_name(self) -> None:
masked = guardrail.mask_pii("아직 정해진 건 없어요. 진로는 그대로고 엄마한테 말도 못 했어요.")
self.assertEqual(
masked.text_masked,
"아직 정해진 건 없어요. 진로는 그대로고 엄마한테 말도 못 했어요.",
)
self.assertNotIn("NAME", masked.entities)
def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
@ -316,7 +328,11 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
)
]
self.assertEqual([event.event for event in events], ["token", "done"])
self.assertEqual(events[-1].event, "done")
self.assertEqual(
"".join(str(event.data.get("text", "")) for event in events if event.event == "token"),
"Masked stream reply.",
)
self.assertIsNotNone(engine.request)
self.assertIsNotNone(engine.payload)
_assert_no_raw_pii(self, engine.request.messages)
@ -359,6 +375,53 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
_assert_masked_ko_pii_present(self, engine.payload)
_assert_no_raw_ko_pii(self, audit_payloads)
async def test_run_turn_generate_humanizes_masked_placeholder_reply(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text="진로 이야기를 이어가고 싶어요.",
)
engine = CaptureGenerateEngine(
text="아직 정해진 건 하나도 없어요. [NAME]는 그대로고, 엄마한테 뭐라고 말할지도 모르겠고요."
)
result = await orchestrator.run_turn_generate(
ctx,
engine, # type: ignore[arg-type]
)
self.assertNotIn("[NAME]", result.client_reply or "")
self.assertIn("그 이름은 그대로고", result.client_reply or "")
async def test_run_turn_stream_humanizes_split_masked_placeholder_reply(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text="진로 이야기를 이어가고 싶어요.",
)
engine = CaptureStreamEngine(
chunks=["아직 정해진 건 하나도 없어요. ", "[NA", "ME]는", " 그대로고요."]
)
events = [
event
async for event in orchestrator.run_turn_stream(
ctx,
engine, # type: ignore[arg-type]
)
]
streamed = "".join(
str(event.data.get("text", "")) for event in events if event.event == "token"
)
self.assertNotIn("[NAME]", streamed)
self.assertIn("그 이름은 그대로고요.", streamed)
self.assertEqual(events[-1].event, "done")
if __name__ == "__main__":
unittest.main()

View file

@ -61,7 +61,7 @@ class PiiMaskingEvalTests(unittest.TestCase):
def test_fixture_cases_are_valid_json_list(self) -> None:
cases = load_cases(FIXTURE_PATH)
self.assertEqual(len(cases), 15)
self.assertEqual(len(cases), 16)
self.assertTrue(all(case.get("id") for case in cases))
self.assertTrue(all(case.get("text") for case in cases))
self.assertTrue(all(case.get("locale") == "ko-KR" for case in cases))
@ -80,13 +80,13 @@ class PiiMaskingEvalTests(unittest.TestCase):
self.assertEqual(report["data_source"], "local_fixture")
self.assertFalse(report["evidence_text_included"])
self.assertTrue(report["passed"], report)
self.assertEqual(report["cases_total"], 15)
self.assertEqual(report["cases_passed"], 15)
self.assertEqual(report["cases_total"], 16)
self.assertEqual(report["cases_passed"], 16)
self.assertEqual(report["cases_failed"], 0)
self.assertEqual(report["expected_entity_recall"], 1.0)
self.assertEqual(report["forbidden_substring_removal"], 1.0)
self.assertEqual(report["unexpected_entity_violations"], 0)
self.assertEqual(report["by_source"]["synthetic"]["cases_passed"], 15)
self.assertEqual(report["by_source"]["synthetic"]["cases_passed"], 16)
self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES)
self.assertEqual(report["by_severity"]["critical"]["cases_passed"], 2)
for result in report["results"]:
@ -105,6 +105,9 @@ class PiiMaskingEvalTests(unittest.TestCase):
"저는 최하늘입니다.": "저는 [NAME]입니다.",
"김서연입니다.": "[NAME]입니다.",
"이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.": "이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.",
"아직 정해진 건 없어요. 진로는 그대로고 엄마한테 말도 못 했어요.": (
"아직 정해진 건 없어요. 진로는 그대로고 엄마한테 말도 못 했어요."
),
}
for raw, expected in cases.items():
@ -176,7 +179,7 @@ class PiiMaskingEvalTests(unittest.TestCase):
report = json.loads(completed.stdout)
self.assertTrue(report["passed"])
self.assertEqual(report["cases_total"], 15)
self.assertEqual(report["cases_total"], 16)
self.assertFalse(report["evidence_text_included"])
self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES)
self.assertTrue(all("masked_text" not in result for result in report["results"]))

View file

@ -160,6 +160,152 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("does not belong", caught.exception.detail)
review_ready.assert_not_awaited()
async def test_super_admin_primary_learner_can_review_other_learner_session(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000103",
)
super_admin = Principal(
user_id="00000000-0000-0000-0000-000000000303",
role=Role.LEARNER,
admin_access=True,
super_admin=True,
email="yunchan@twentyoz.kr",
display_name="Yun Chan",
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000a103",
learner_id=owner.user_id,
)
store.put(sess)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=None),
) as load_session,
patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True),
):
loaded, review_principal = await sessions._load_review_session_or_404(
sess.session_id,
super_admin,
)
self.assertIs(loaded, sess)
self.assertEqual(review_principal.role, Role.ADMIN)
self.assertEqual(load_session.await_count, 2)
async def test_super_admin_primary_learner_review_loaders_use_admin_principal(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000105",
)
super_admin = Principal(
user_id="00000000-0000-0000-0000-000000000305",
role=Role.LEARNER,
admin_access=True,
super_admin=True,
email="yunchan@twentyoz.kr",
display_name="Yun Chan",
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000a105",
learner_id=owner.user_id,
)
sess.ended = True
sess.ended_at = 1_800_000_120.0
sess.turns.extend(
[
_turn(seq=1, speaker="counselor", text="admin-visible learner turn"),
_turn(seq=2, speaker="client", text="admin-visible client turn"),
]
)
store.put(sess)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=None),
),
patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True),
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
) as load_session_evaluation,
patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(None, False)),
) as load_case_worksheet,
patch.object(
sessions.session_persistence,
"load_session_review_status",
AsyncMock(
return_value=(
{
"status": "viewed",
"reviewer_id": "00000000-0000-0000-0000-000000000999",
},
True,
)
),
) as load_session_review_status,
):
response = await sessions.get_session_review(sess.session_id, super_admin)
load_session_evaluation.assert_awaited_once()
load_case_worksheet.assert_awaited_once()
load_session_review_status.assert_awaited_once()
evaluation_principal = load_session_evaluation.await_args.args[1]
worksheet_principal = load_case_worksheet.await_args.args[1]
review_status_principal = load_session_review_status.await_args.args[1]
self.assertEqual(load_session_evaluation.await_args.args[0], sess.session_id)
self.assertEqual(load_case_worksheet.await_args.args[0], sess.session_id)
self.assertEqual(load_session_review_status.await_args.args[0], sess.session_id)
self.assertIs(evaluation_principal, worksheet_principal)
self.assertIs(evaluation_principal, review_status_principal)
self.assertEqual(evaluation_principal.user_id, super_admin.user_id)
self.assertEqual(evaluation_principal.role, Role.ADMIN)
self.assertTrue(evaluation_principal.super_admin)
self.assertEqual(response.session_id, sess.session_id)
self.assertIsNotNone(response.teacherReview)
self.assertEqual(response.teacherReview.status, "viewed")
async def test_admin_access_flag_alone_does_not_review_other_learner_session(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000104",
)
delegated_admin = Principal(
user_id="00000000-0000-0000-0000-000000000304",
role=Role.LEARNER,
admin_access=True,
super_admin=False,
email="delegate@example.test",
display_name="Delegated Admin",
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000a104",
learner_id=owner.user_id,
)
store.put(sess)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=None),
),
patch.object(sessions.turn_runtime, "runtime_fallback_allowed", return_value=True),
):
with self.assertRaises(HTTPException) as caught:
await sessions._load_review_session_or_404(sess.session_id, delegated_admin)
self.assertEqual(caught.exception.status_code, 403)
self.assertIn("does not belong", caught.exception.detail)
async def test_session_detail_filters_evaluator_only_turns(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000111",

View file

@ -146,7 +146,7 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
"has_engine_config": False,
"has_session_columns": False,
"has_state_columns": False,
"has_turn_provider_events": False,
"has_turn_voice_metadata_columns": False,
"has_session_review_worksheet_columns": False,
"has_stage_defs": False,
"has_admin_health_event": False,
@ -185,6 +185,50 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("runtime DB schema is incomplete", str(caught.exception))
async def test_runtime_readiness_requires_all_turn_voice_metadata_columns(self) -> None:
class VoiceMetadataDriftConn:
def __init__(self) -> None:
self.query = ""
async def fetchrow(self, query: str, *args, **kwargs):
self.query = query
return {
"has_user_columns": True,
"has_persona_triggers": True,
"has_persona_voice_map": True,
"has_auth_session": True,
"has_preferences": True,
"has_engine_config": True,
"has_session_columns": True,
"has_state_columns": True,
"has_turn_voice_metadata_columns": False,
"has_session_review_worksheet_columns": True,
"has_stage_defs": True,
"has_admin_health_event": True,
"has_admin_health_daily_rollup": True,
"has_admin_health_daily_rollup_columns": True,
"has_support_ticket": True,
"has_support_ticket_duplicate_columns": True,
"has_learner_prepost_measure": True,
"has_admin_health_event_policies": True,
"has_admin_health_daily_rollup_policies": True,
"has_support_ticket_policies": True,
"has_learner_prepost_measure_policies": True,
"has_session_write_policies": True,
"removed_old_session_policy": True,
"has_turn_write_policies": True,
"removed_old_turn_policy": True,
}
conn = VoiceMetadataDriftConn()
ready = await auth_sessions._runtime_tables_ready(conn)
self.assertFalse(ready)
for column in ("audio_ref", "silence_ms", "speech_rate", "barge_in", "provider_events"):
self.assertIn(column, conn.query)
self.assertIn("HAVING count(*) = 5", conn.query)
async def test_staging_uses_env_engine_config_when_row_missing(self) -> None:
class EmptyConfigConn:
async def fetchrow(self, *args, **kwargs):

View file

@ -447,6 +447,49 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
legacy_history = await sessions.list_live_coach_history(sess.session_id, principal)
self.assertIsNone(legacy_history.events[0].stage)
async def test_live_coach_persistence_failure_is_not_swallowed(self) -> None:
principal = _principal()
sess = _session(principal)
suggestion = live_coach.LiveCoachSuggestion(
status="ready",
tone="pos",
focus="emotion",
title="감정 반영이 선명합니다",
message="학습자가 내담자의 감정을 먼저 되짚었습니다.",
)
save_error = sessions.HTTPException(
status_code=sessions.status.HTTP_503_SERVICE_UNAVAILABLE,
detail="live coach event save persistence unavailable; runtime fallback is disabled in prod",
)
with patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
), patch.object(
sessions.live_coach,
"generate_live_coaching",
AsyncMock(return_value=suggestion),
), patch.object(
session_persistence,
"save_live_coach_event",
AsyncMock(side_effect=save_error),
) as save_event:
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.live_coach_turn(
sess.session_id,
sessions.LiveCoachRequest(
learner_text="그 마음이 컸겠네요.",
client_reply="네, 아무도 몰라주는 것 같았어요.",
turn_seq=1,
),
principal,
)
self.assertEqual(caught.exception.status_code, 503)
self.assertIn("live coach event save", str(caught.exception.detail))
save_event.assert_awaited_once()
async def test_live_coach_event_normalizes_legacy_stage_values(self) -> None:
suggestion = live_coach.LiveCoachSuggestion(
status="degraded",
@ -987,6 +1030,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
"category": "speech_activity",
"start_ms": 100,
},
{
"event_type": "silence",
"category": "timing",
"duration_ms": 1234,
},
{
"event_type": "background_noise",
"category": "audio_quality",
@ -1021,6 +1069,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(learner_turn.nonverbal[0].label, "침묵")
self.assertEqual(learner_turn.nonverbal[0].detail, "1.2초")
self.assertEqual([event.kind for event in learner_turn.nonverbal].count("silence"), 1)
self.assertEqual(learner_turn.nonverbal[1].detail, "분당 420자")
self.assertEqual(learner_turn.nonverbal[4].label, "음성 단서")
self.assertEqual(learner_turn.nonverbal[4].detail, "한숨 감지 · 신뢰도 82%")
@ -1191,6 +1240,66 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("주호소", response.teacherReview.worksheetNote)
self.assertEqual(response.teacherReview.worksheetReviewedAt, "2026-06-27T10:05:00Z")
async def test_review_surfaces_session_evaluation_error_record(self) -> None:
principal = _principal()
sess = _session(principal)
sess.ended = True
sess.ended_at = sess.created_at + 180
sess.turns.extend(
[
TurnRecord(
turn_seq=1,
speaker="learner",
stage=sess.state.stage.value,
text="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?",
text_masked="많이 지쳐 보였어요. 지금 제일 버거운 마음이 뭔가요?",
created_at=sess.created_at + 1,
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=sess.state.stage.value,
text="그냥 아무것도 하고 싶지 않아요.",
text_masked="그냥 아무것도 하고 싶지 않아요.",
created_at=sess.created_at + 2,
),
]
)
error_record = session_persistence.SessionEvaluationWrite.from_error(
session_id=sess.session_id,
learner_id=principal.user_id,
scope="session_end",
stage="정리",
error="session evaluation timeout after 45s",
).cache_record()
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=sess),
),
patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(error_record, True)),
),
):
response = await sessions.get_session_review(sess.session_id, principal)
self.assertFalse(response.reviewReady)
self.assertTrue(response.degraded)
self.assertEqual(response.supervisorState, "평가 실패")
self.assertIn("session evaluation timeout after 45s", response.summary)
self.assertEqual(response.rubric, [])
self.assertEqual(response.goodMoments, [])
self.assertEqual(response.growthPoints, [])
async def test_learner_can_save_case_formulation_worksheet(self) -> None:
principal = _principal()
sess = _session(principal)

View file

@ -5,6 +5,8 @@ from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .deps import Principal, Role
from .routes import teacher
from .services import state_machine
@ -244,6 +246,88 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("보호요인", response.worksheet_note)
self.assertEqual(response.worksheet_reviewed_at, "2026-06-27T10:05:00Z")
async def test_learner_analysis_returns_full_ordered_history(self) -> None:
learner_id = "00000000-0000-0000-0000-000000000111"
other_learner_id = "00000000-0000-0000-0000-000000000222"
learner_sessions = [
_session(
session_id=f"analysis-session-{idx}",
session_no=idx,
learner_id=learner_id,
score="pos" if idx >= 5 else "neutral",
rapport=0.1 * idx,
technique="reflection" if idx % 2 else "open question",
created_at=1_000.0 + idx,
)
for idx in range(1, 8)
]
sessions = [
learner_sessions[6],
_session(
session_id="analysis-other-session",
session_no=1,
learner_id=other_learner_id,
score="warn",
rapport=-0.2,
technique="advice",
created_at=1_500.0,
),
*learner_sessions[:6],
]
principal = _principal()
with (
patch.object(
teacher.session_persistence,
"list_sessions",
AsyncMock(return_value=(sessions, True)),
) as list_sessions,
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(
return_value=(
{
"analysis-session-2": {
"session_id": "analysis-session-2",
"status": "closed",
"note": "확인 완료",
"reviewed_at": "2026-06-27T10:00:00Z",
}
},
True,
)
),
),
):
response = await teacher.learner_analysis(learner_id, principal)
list_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True)
self.assertEqual(response.source, "database")
self.assertEqual(response.learner_id, learner_id)
self.assertEqual(response.total_sessions, 7)
self.assertEqual(response.ended_sessions, 7)
self.assertEqual(response.pending_reviews, 6)
self.assertEqual(response.closed_reviews, 1)
self.assertEqual([session.session_no for session in response.sessions], [1, 2, 3, 4, 5, 6, 7])
self.assertEqual([point.session_no for point in response.points], [1, 2, 3, 4, 5, 6, 7])
self.assertEqual(response.sessions[1].review_status, "closed")
explore = next(item for item in response.stage_breakdown if item.stage == "탐색")
self.assertEqual(explore.sessions, 7)
self.assertEqual(explore.turns, 14)
async def test_learner_analysis_returns_404_for_out_of_scope_learner(self) -> None:
principal = _principal()
with patch.object(
teacher.session_persistence,
"list_sessions",
AsyncMock(return_value=([], True)),
):
with self.assertRaises(HTTPException) as raised:
await teacher.learner_analysis("missing-learner", principal)
self.assertEqual(raised.exception.status_code, 404)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,336 @@
import http, { type Server } from "node:http";
import { promises as fs } from "node:fs";
import { AddressInfo } from "node:net";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, test, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
const repoRoot = path.resolve(process.cwd(), "..", "..");
const dashboardPath = path.join(repoRoot, "docs", "dev_dashboard.html");
interface StaticServer {
port: number;
close: () => Promise<void>;
}
interface DashboardMetrics {
total: number;
done: number;
doing: number;
planned: number;
ownerCards: number;
ownerBoard: number;
crit: number;
ui: Record<string, string>;
ownerColumns: Record<string, number>;
visibleCards: number;
visibleGroups: number;
phaseSegments: number;
trackRows: number;
trackNames: string[];
stackSegments: string[];
}
async function startStaticServer(root: string): Promise<StaticServer> {
const server: Server = http.createServer(async (req, res) => {
try {
const requestUrl = new URL(req.url ?? "/", "http://127.0.0.1");
const requestPath =
requestUrl.pathname === "/" ? "/docs/dev_dashboard.html" : decodeURIComponent(requestUrl.pathname);
const normalized = path.normalize(requestPath).replace(/^([/\\])+/, "");
const filePath = path.join(root, normalized);
const relative = path.relative(root, filePath);
if (relative.startsWith("..") || path.isAbsolute(relative)) {
res.writeHead(403).end("Forbidden");
return;
}
const body = await fs.readFile(filePath);
res.writeHead(200, { "content-type": contentType(filePath) });
res.end(body);
} catch {
res.writeHead(404).end("Not found");
}
});
await new Promise<void>((resolve) => server.listen(0, resolve));
const address = server.address();
if (!address || typeof address === "string") {
throw new Error("Static dashboard server did not expose a TCP port");
}
return {
port: (address as AddressInfo).port,
close: () => new Promise<void>((resolve, reject) => server.close((err) => (err ? reject(err) : resolve()))),
};
}
function contentType(filePath: string) {
const ext = path.extname(filePath).toLowerCase();
if (ext === ".html") return "text/html; charset=utf-8";
if (ext === ".css") return "text/css; charset=utf-8";
if (ext === ".js" || ext === ".mjs") return "text/javascript; charset=utf-8";
if (ext === ".png") return "image/png";
if (ext === ".svg") return "image/svg+xml";
return "application/octet-stream";
}
async function openDashboard(page: Page, url: string) {
const errors: string[] = [];
page.on("console", (msg) => {
if (msg.type() === "error") errors.push(msg.text());
});
page.on("pageerror", (err) => errors.push(err.message));
const response = await page.goto(url, { waitUntil: "domcontentloaded" });
if (url.startsWith("http")) {
expect(response?.ok(), `${url} should load over HTTP`).toBeTruthy();
}
await expect(page.locator("#phase-rail .pr-seg")).toHaveCount(5);
expect(errors).toEqual([]);
}
async function readMetrics(page: Page): Promise<DashboardMetrics> {
return await page.evaluate(() => {
const cards = Array.from(document.querySelectorAll<HTMLElement>(".scard"));
const statusCount = (status: string) => cards.filter((card) => card.dataset.status === status).length;
const text = (selector: string) => document.querySelector(selector)?.textContent?.trim() ?? "";
const countOwnerColumn = (column: string) =>
document.querySelector(`[data-owner-col="${column}"]`)?.querySelectorAll('.ocard[data-owner="1"]').length ?? 0;
return {
total: cards.length,
done: statusCount("done"),
doing: statusCount("doing"),
planned: cards.length - statusCount("done") - statusCount("doing"),
ownerCards: cards.filter((card) => card.dataset.owner === "1").length,
ownerBoard: document.querySelectorAll('.ocard[data-owner="1"]').length,
crit: cards.filter((card) => card.querySelector(".critbadge")).length,
ui: {
kpiDone: text("#kpi-done"),
kpiDoing: text("#kpi-doing"),
kpiPlan: text("#kpi-plan"),
kpiOwner: text("#kpi-owner"),
pillOwner: text("#pill-owner"),
total: text("#g-total"),
legendDone: text("#lg-done"),
legendDoing: text("#lg-doing"),
legendPlan: text("#lg-plan"),
filterAll: text('[data-count="all"]'),
filterDone: text('[data-count="done"]'),
filterDoing: text('[data-count="doing"]'),
filterPlanned: text('[data-count="planned"]'),
filterOwner: text('[data-count="owner"]'),
filterCrit: text('[data-count="crit"]'),
},
ownerColumns: {
block: countOwnerColumn("block"),
decide: countOwnerColumn("decide"),
ext: countOwnerColumn("ext"),
},
visibleCards: cards.filter((card) => !card.classList.contains("hide")).length,
visibleGroups: Array.from(document.querySelectorAll<HTMLElement>("#board .track-group")).filter(
(group) => getComputedStyle(group).display !== "none",
).length,
phaseSegments: document.querySelectorAll("#phase-rail .pr-seg").length,
trackRows: document.querySelectorAll("#tracks .track").length,
trackNames: Array.from(document.querySelectorAll("#tracks .track .tname")).map(
(el) => el.textContent?.trim() ?? "",
),
stackSegments: Array.from(document.querySelectorAll("#stackbar i")).map((el) => el.textContent?.trim() ?? ""),
};
});
}
function expectDerivedMetrics(metrics: DashboardMetrics) {
expect(metrics.total).toBeGreaterThan(0);
expect(metrics.done).toBe(metrics.total);
expect(metrics.doing).toBe(0);
expect(metrics.planned).toBe(0);
expect(metrics.ownerBoard).toBe(
metrics.ownerColumns.block + metrics.ownerColumns.decide + metrics.ownerColumns.ext,
);
expect(metrics.ownerBoard).toBeGreaterThan(0);
expect(metrics.crit).toBe(3);
expect(metrics.ui).toMatchObject({
kpiDone: String(metrics.done),
kpiDoing: String(metrics.doing),
kpiPlan: String(metrics.planned),
kpiOwner: String(metrics.ownerBoard),
pillOwner: String(metrics.ownerBoard),
total: String(metrics.total),
legendDone: String(metrics.done),
legendDoing: String(metrics.doing),
legendPlan: String(metrics.planned),
filterAll: String(metrics.total),
filterDone: String(metrics.done),
filterDoing: String(metrics.doing),
filterPlanned: String(metrics.planned),
filterOwner: String(metrics.ownerCards),
filterCrit: String(metrics.crit),
});
expect(metrics.phaseSegments).toBe(5);
expect(metrics.trackRows).toBeGreaterThan(1);
expect(metrics.trackNames).not.toContain("");
expect(metrics.stackSegments).toEqual([String(metrics.done)]);
}
async function expectVisibleCards(page: Page, expected: number) {
await expect
.poll(async () => (await readMetrics(page)).visibleCards, { message: `expected ${expected} visible cards` })
.toBe(expected);
}
test.describe.configure({ mode: "serial" });
test.describe("dev dashboard static command center", () => {
let staticServer: StaticServer;
test.beforeAll(async () => {
staticServer = await startStaticServer(repoRoot);
});
test.afterAll(async () => {
await staticServer.close();
});
test("derives counters and graphs on local file and HTTP origins", async ({ page }) => {
const urls = [
pathToFileURL(dashboardPath).toString(),
`http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`,
`http://localhost:${staticServer.port}/docs/dev_dashboard.html`,
];
for (const url of urls) {
await openDashboard(page, url);
expectDerivedMetrics(await readMetrics(page));
}
});
test("keeps filters stable under empty states, wrong order, and rapid retries", async ({ page }) => {
await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`);
const initial = await readMetrics(page);
expectDerivedMetrics(initial);
await page.locator('[data-filter="planned"]').click();
await expectVisibleCards(page, 0);
await expect(page.locator("#board .track-group").first()).not.toBeVisible();
await page.locator('[data-filter="done"]').focus();
await page.keyboard.press("Enter");
await expect(page.locator('[data-filter="done"]')).toHaveAttribute("aria-pressed", "true");
await expectVisibleCards(page, initial.done);
await page.locator('[data-filter="planned"]').focus();
await page.keyboard.press("Space");
await expect(page.locator('[data-filter="planned"]')).toHaveAttribute("aria-pressed", "true");
await expectVisibleCards(page, 0);
const rapidSequence = ["all", "crit", "doing", "owner", "planned", "done", "all", "crit", "all"];
await page.evaluate((filters) => {
for (const filter of filters) {
document
.querySelector<HTMLElement>(`[data-filter="${filter}"]`)
?.dispatchEvent(new MouseEvent("click", { bubbles: true, cancelable: true }));
}
}, rapidSequence);
await expect(page.locator('[data-filter="all"]')).toHaveAttribute("aria-pressed", "true");
await expectVisibleCards(page, initial.total);
expectDerivedMetrics(await readMetrics(page));
const firstCard = page.locator(".scard").first();
const firstHead = firstCard.locator(".scard-head");
await firstHead.click();
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
await expect(firstCard).toHaveClass(/open/);
await page.locator('[data-filter="planned"]').click();
await expectVisibleCards(page, 0);
await page.locator('[data-filter="all"]').click();
await expect(firstHead).toHaveAttribute("aria-expanded", "true");
await firstHead.click();
await expect(firstHead).toHaveAttribute("aria-expanded", "false");
});
test("keeps local links, images, and hash anchors intact", async ({ page }) => {
const dashboardUrl = `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`;
await openDashboard(page, dashboardUrl);
await page.locator(".owner-pill").click();
await expect(page).toHaveURL(/#owner$/);
await expect(page.locator("#owner")).toBeInViewport();
await page.locator(".kpi.k-done").click();
await expect(page).toHaveURL(/#board$/);
await expect(page.locator("#board")).toBeInViewport();
const references = await page.evaluate(() =>
Array.from(document.querySelectorAll<HTMLAnchorElement | HTMLImageElement | HTMLLinkElement>("[href],[src]"))
.map((el) => ({
tag: el.tagName.toLowerCase(),
value: el.getAttribute("href") ?? el.getAttribute("src") ?? "",
}))
.filter((item) => item.value.length > 0),
);
for (const reference of references) {
if (reference.value.startsWith("#")) {
await expect(page.locator(reference.value)).toHaveCount(1);
continue;
}
const resolved = new URL(reference.value, dashboardUrl);
if (resolved.origin !== new URL(dashboardUrl).origin) {
expect(["http:", "https:"], `${reference.tag} ${reference.value}`).toContain(resolved.protocol);
continue;
}
const response = await page.request.get(resolved.toString());
expect(response.ok(), `${reference.tag} ${reference.value} should resolve`).toBeTruthy();
}
});
test("supports tab click and keyboard navigation after repeated opening", async ({ page }) => {
await openDashboard(page, `http://127.0.0.1:${staticServer.port}/docs/dev_dashboard.html`);
for (const foldId of ["fold-roadmap", "fold-tabs", "fold-narrative"]) {
const fold = page.locator(`#${foldId}`);
const summary = fold.locator("summary");
await summary.click();
await expect(fold).toHaveJSProperty("open", true);
await summary.click();
await expect(fold).toHaveJSProperty("open", false);
await summary.click();
await expect(fold).toHaveJSProperty("open", true);
}
const tabs = page.locator('[role="tab"][data-tab]');
const tabCount = await tabs.count();
expect(tabCount).toBeGreaterThan(3);
for (let index = tabCount - 1; index >= 0; index -= 1) {
const tab = tabs.nth(index);
const panelId = await tab.getAttribute("aria-controls");
expect(panelId).toBeTruthy();
await tab.click();
await expect(tab).toHaveAttribute("aria-selected", "true");
await expect(page.locator(`#${panelId}`)).toBeVisible();
await expect(page.locator('[role="tabpanel"]:not([hidden])')).toHaveCount(1);
}
await tabs.first().focus();
await page.keyboard.press("End");
await expect(tabs.nth(tabCount - 1)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("Home");
await expect(tabs.first()).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("ArrowRight");
await expect(tabs.nth(1)).toHaveAttribute("aria-selected", "true");
await page.keyboard.press("ArrowLeft");
await expect(tabs.first()).toHaveAttribute("aria-selected", "true");
});
test("does not overflow at desktop and mobile widths in the simulated external origin", async ({ page }) => {
for (const viewport of [
{ width: 1440, height: 900 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await openDashboard(page, `http://localhost:${staticServer.port}/docs/dev_dashboard.html`);
await expectNoHorizontalOverflow(page);
}
});
});

View file

@ -38,6 +38,93 @@ const GATE_WIDTHS = [
] as const;
const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "layout-gate");
const TEACHER_REVIEW_SESSION_ID = "teacher-review-visual";
function teacherAnalysisFixture() {
const learnerId = "visual-analysis-learner";
const sessions = Array.from({ length: 5 }, (_unused, index) => {
const sessionNo = index + 1;
return {
session_id: `${learnerId}-session-${sessionNo}`,
learner_id: learnerId,
learner_label: "분석 검증 학습자",
persona_code: sessionNo % 2 === 0 ? "P2" : "P1",
persona_name: sessionNo % 2 === 0 ? "민재" : "서연",
session_no: sessionNo,
status: "ended",
stage: sessionNo >= 4 ? "개입" : "탐색",
turn_count: 8 + sessionNo,
learner_turn_count: 4 + sessionNo,
client_turn_count: 4,
started_at: `2026-06-${String(10 + sessionNo).padStart(2, "0")}T09:00:00Z`,
ended_at: `2026-06-${String(10 + sessionNo).padStart(2, "0")}T09:30:00Z`,
review_status: sessionNo === 3 ? "closed" : "pending",
review_note: null,
reviewed_at: sessionNo === 3 ? "2026-06-13T10:00:00Z" : null,
};
});
const points = sessions.map((session) => ({
session_id: session.session_id,
session_no: session.session_no,
persona_code: session.persona_code,
stage: session.stage,
started_at: session.started_at,
ended_at: session.ended_at,
score: session.session_no >= 4 ? 0.88 : 0.52,
rapport: Math.min(0.8, session.session_no * 0.12),
technique_count: session.session_no + 1,
watch_count: session.session_no < 4 ? 1 : 0,
}));
const summary = {
learner_id: learnerId,
learner_label: "분석 검증 학습자",
sessions: sessions.length,
ended_sessions: sessions.length,
latest_at: sessions[sessions.length - 1].ended_at ?? "",
first_score: points[0].score,
latest_score: points[points.length - 1].score,
score_delta: (points[points.length - 1].score ?? 0) - (points[0].score ?? 0),
avg_score: 0.72,
avg_rapport: 0.44,
trend: "up",
top_techniques: ["reflection", "summary"],
points,
};
return {
dashboard: {
source: "database",
cohort_label: "Visual cohort",
total_learners: 1,
active_sessions: 0,
ended_sessions: sessions.length,
learner_growth: [{ ...summary, points: points.slice(-4) }],
safety_alerts: [],
pending_reviews: [],
recent_sessions: sessions.slice(-2),
message: "학생 분석 시각 검증 fixture.",
},
analysis: {
source: "database",
learner_id: learnerId,
learner_label: "분석 검증 학습자",
total_sessions: sessions.length,
ended_sessions: sessions.length,
active_sessions: 0,
pending_reviews: 2,
summary,
points,
stage_breakdown: [
{ stage: "라포", sessions: 0, turns: 0 },
{ stage: "탐색", sessions: 3, turns: 31 },
{ stage: "개입", sessions: 2, turns: 22 },
{ stage: "정리", sessions: 0, turns: 0 },
],
sessions,
message: "분석 검증 학습자 전체 회기 5건",
},
};
}
async function ensureShotDir() {
await fs.mkdir(SHOT_DIR, { recursive: true });
@ -284,6 +371,76 @@ async function expectFilledReviewLearnerWorkbench(page: Page) {
}
}
async function expectSupervisorReviewNoDeadGaps(page: Page) {
const report = await page.evaluate(() => {
const cols = document.querySelector<HTMLElement>(".sr-cols--supervisor");
const left = document.querySelector<HTMLElement>(".sr-cols--supervisor .sr-left");
const right = document.querySelector<HTMLElement>(".sr-cols--supervisor .sr-right");
if (!cols || !left || !right) {
return {
present: false,
viewportWidth: window.innerWidth,
columnCount: 0,
leftDisplay: "",
rightDisplay: "",
maxMainGap: 0,
maxSideGap: 0,
};
}
function maxVerticalGap(container: HTMLElement) {
const intervals = Array.from(container.children)
.map((child) => child.getBoundingClientRect())
.filter((rect) => rect.width > 0 && rect.height > 0)
.map((rect) => ({ top: rect.top, bottom: rect.bottom }))
.sort((a, b) => a.top - b.top || a.bottom - b.bottom);
if (intervals.length < 2) return 0;
let maxGap = 0;
let currentBottom = intervals[0].bottom;
for (let index = 1; index < intervals.length; index += 1) {
const next = intervals[index];
if (next.top <= currentBottom) {
currentBottom = Math.max(currentBottom, next.bottom);
continue;
}
const gap = next.top - currentBottom;
if (gap > maxGap) maxGap = gap;
currentBottom = next.bottom;
}
return Math.round(maxGap);
}
const colsStyle = getComputedStyle(cols);
const leftStyle = getComputedStyle(left);
const rightStyle = getComputedStyle(right);
return {
present: true,
viewportWidth: window.innerWidth,
columnCount: colsStyle.gridTemplateColumns.split(" ").filter(Boolean).length,
leftDisplay: leftStyle.display,
rightDisplay: rightStyle.display,
maxMainGap: maxVerticalGap(left),
maxSideGap: maxVerticalGap(right),
};
});
expect(report.present, "supervisor review layout should be mounted").toBe(true);
if (report.viewportWidth > 1180) {
expect(report.columnCount, "desktop supervisor review should use main + review rail").toBe(2);
expect(report.leftDisplay).toBe("grid");
expect(report.rightDisplay).toBe("grid");
expect(
report.maxMainGap,
"desktop supervisor review main column should not leave a dead vertical void",
).toBeLessThanOrEqual(40);
expect(
report.maxSideGap,
"desktop supervisor review side rail should not leave a dead vertical void",
).toBeLessThanOrEqual(40);
}
}
test.describe("layout visual gate @single-run", () => {
test.describe.configure({ mode: "serial" });
@ -367,6 +524,18 @@ test.describe("layout visual gate @single-run", () => {
});
});
test("professor session review avoids dead vertical gaps across all widths", async ({ page }) => {
await signInAsTeacher(page);
await routeFilledSessionReview(page, TEACHER_REVIEW_SESSION_ID);
await page.goto(`/teach/session/${TEACHER_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review-professor", async () => {
await expect(page.locator(".sr-cols--supervisor")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expectSupervisorReviewNoDeadGaps(page);
});
});
test("empty session review avoids sparse column gaps across all widths", async ({ page }) => {
await signInAsLearner(page);
await routeEmptySessionReview(page);
@ -390,6 +559,33 @@ test.describe("layout visual gate @single-run", () => {
});
});
test("professor student analysis stays contained across all widths", async ({ page }) => {
const fixture = teacherAnalysisFixture();
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(fixture.dashboard),
}),
);
await page.route("**/api/teacher/learners/*/analysis", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(fixture.analysis),
}),
);
await signInAsTeacher(page);
await page.goto("/teach/analysis");
await gateScreen(page, "professor-analysis", async () => {
await expect(page.locator(".pf-analysis-shell")).toBeVisible({ timeout: 15_000 });
await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({
timeout: 15_000,
});
await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5);
});
});
test("persona studio stays contained across all widths", async ({ page }) => {
await signInAsTeacher(page);
await page.goto("/teach/personas");

View file

@ -93,6 +93,43 @@ async function expectReachableLearnerHomeLayout(page: import("@playwright/test")
await expectNoHorizontalOverflow(page);
}
async function expectPersonaCardsUseContentHeight(page: import("@playwright/test").Page) {
const cards = await page.locator(".lh-persona").evaluateAll((items) =>
items.map((item) => {
const card = item as HTMLElement;
const rect = card.getBoundingClientRect();
const summary = card.querySelector<HTMLElement>(".lh-persona__summary");
const body = card.querySelector<HTMLElement>(".lh-persona__body");
const textNodes = Array.from(
card.querySelectorAll<HTMLElement>(
".lh-persona__name, .lh-persona__meta, .lh-persona__summary",
),
);
const childBottom = Math.max(
0,
...textNodes.map((child) => child.getBoundingClientRect().bottom),
);
return {
label: (card.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 90),
height: Math.ceil(rect.height),
bodyOverflow: body ? Math.ceil(body.scrollHeight - body.clientHeight) : 0,
childBottomOverflow: Math.ceil(childBottom - rect.bottom),
cardScrollOverflow: Math.ceil(card.scrollHeight - card.clientHeight),
summaryOverflow: summary ? Math.ceil(summary.scrollHeight - summary.clientHeight) : 0,
};
}),
);
const offenders = cards.filter(
(card) =>
card.bodyOverflow > 1 ||
card.childBottomOverflow > 1 ||
card.cardScrollOverflow > 1 ||
card.summaryOverflow > 1,
);
expect(offenders, `Persona card text should fit its fluid-height card: ${JSON.stringify(cards)}`).toEqual([]);
}
function learnerActivityHeading(page: import("@playwright/test").Page) {
return page.locator(".lh-activity__head").getByText("기존 회기", { exact: true });
}
@ -161,6 +198,7 @@ test.describe("learner app shell and session launcher", () => {
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible();
}
await expectPersonaCardsUseContentHeight(page);
await expect(page.getByText(/12회/)).toHaveCount(0);
await expect(page.getByText(/최근 연습/)).toHaveCount(0);

View file

@ -12,8 +12,15 @@ function deferred() {
return { promise, resolve };
}
async function routeMvpApi(page: Page) {
interface RouteMvpOptions {
endStatus?: number;
endBody?: unknown;
crisisStream?: boolean;
}
async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
const sessionStartRequests: unknown[] = [];
const liveCoachRequests: unknown[] = [];
const streamSeen = deferred();
const streamGate = deferred();
@ -26,6 +33,10 @@ async function routeMvpApi(page: Page) {
email: "mvp.learner@hs.ac.kr",
display_name: "MVP Learner",
role: "learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: Math.floor(Date.now() / 1000),
onboarding_completed_at: Math.floor(Date.now() / 1000),
@ -36,6 +47,21 @@ async function routeMvpApi(page: Page) {
});
});
await page.route("**/api/users/me/prepost-measures**", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
pilot_id: "phase3-pilot-draft",
instrument_version: "pilot-prepost-scaffold-2026-06-28",
measures: [],
complete_pre_count: 0,
complete_post_count: 0,
updated_at: null,
}),
});
});
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
@ -102,6 +128,37 @@ async function routeMvpApi(page: Page) {
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
streamSeen.resolve();
await streamGate.promise;
if (options.crisisStream) {
const crisisResource = {
title: "자살예방상담전화 109",
number: "109",
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
};
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: safety",
`data: ${JSON.stringify({
crisis_resource: crisisResource,
conversation_stopped: true,
})}`,
"",
"event: done",
`data: ${JSON.stringify({
session_id: sessionId,
stage: "라포",
effective_openness: 0.21,
turn_seq: 1,
safety_flagged: true,
crisis_resource: crisisResource,
conversation_stopped: true,
})}`,
"",
].join("\n"),
});
return;
}
await route.fulfill({
status: 200,
contentType: "text/event-stream",
@ -123,14 +180,55 @@ async function routeMvpApi(page: Page) {
});
await page.route(`**/api/sessions/${sessionId}/end`, async (route) => {
const status = options.endStatus ?? 200;
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(
options.endBody ?? {
session_id: sessionId,
session_no: 1,
digest_pending: true,
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
},
),
});
});
await page.route(`**/api/sessions/${sessionId}/live-coach`, async (route) => {
const request = route.request();
if (request.method() === "GET") {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ source: "runtime", events: [] }),
});
return;
}
liveCoachRequests.push(request.postDataJSON());
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
session_no: 1,
digest_pending: true,
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
status: "ready",
tone: "pos",
focus: "reflection",
title: "감정 반영이 선명합니다",
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
rationale: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
sources: [
{
source_id: "live_coaching_workbook_0615",
title: "0615 사례개념화 워크북",
locator: "reflection",
kb_kind: "source_pack",
version: "2026-06-15",
citation: "허가된 요약 근거",
},
],
safety_note: null,
latency_ms: 12,
}),
});
});
@ -192,7 +290,7 @@ async function routeMvpApi(page: Page) {
});
});
return { sessionStartRequests, streamGate, streamSeen };
return { sessionStartRequests, liveCoachRequests, streamGate, streamSeen };
}
test.describe("P1 MVP core loop", () => {
@ -239,4 +337,67 @@ test.describe("P1 MVP core loop", () => {
await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible();
await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible();
});
test("shows AI tutor coaching in coached mode after a completed turn", async ({ page }) => {
const api = await routeMvpApi(page);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await api.streamSeen.promise;
api.streamGate.resolve();
await expect(page.locator(".sx-coach-card").getByText("감정 반영이 선명합니다")).toBeVisible();
await expect(
page.getByText("학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다."),
).toBeVisible();
expect(api.liveCoachRequests).toHaveLength(1);
expect(api.liveCoachRequests[0]).toMatchObject({
learner_text: learnerText,
client_reply: clientReply,
turn_seq: 1,
});
});
test("keeps crisis safety gate visible instead of showing empty client reply @single-run", async ({ page }) => {
const api = await routeMvpApi(page, { crisisStream: true });
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await page.getByLabel("학습자 발화 입력").fill("저 지금 자살하고 싶어요. 도와주세요.");
await page.getByRole("button", { name: "보내기" }).click();
await api.streamSeen.promise;
api.streamGate.resolve();
await expect(page.locator(".sx-crisis-resource").getByText("자살예방상담전화 109")).toBeVisible();
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
await expect(
page.locator(".sx-signal__one-text").filter({ hasText: "위기 안전게이트 작동" }),
).toBeVisible();
await expect(page.getByText("내담자 응답 없음")).toHaveCount(0);
await expect(page.locator(".sx-utt.is-client")).toHaveCount(0);
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
expect(api.liveCoachRequests).toHaveLength(0);
});
test("does not navigate to review when session end fails", async ({ page }) => {
await routeMvpApi(page, {
endStatus: 503,
endBody: { detail: "session end persistence unavailable" },
});
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}$`));
await expect(page.getByRole("alert")).toContainText("회기 종료에 실패했습니다.");
await expect(page.getByRole("alert")).toContainText("session end persistence unavailable");
});
});

View file

@ -6,12 +6,363 @@ interface SessionStartResponse {
degraded: boolean;
}
interface HealthResponse {
db?: boolean;
engine?: boolean;
}
interface ReviewTurn {
speaker: string;
text: string;
nonverbal?: Array<{
kind: string;
label: string;
detail: string;
}>;
}
interface SessionReviewResponse {
session_id: string;
turns: ReviewTurn[];
degraded?: boolean;
reviewReady?: boolean;
supervisorState?: string;
summary?: string;
caseWorksheet?: {
status?: string;
sections?: Array<{
key: string;
items?: Array<{
key: string;
value?: string | null;
}>;
}>;
};
teacherReview?: {
worksheetStatus?: string | null;
worksheetNote?: string | null;
} | null;
}
interface LiveCoachEvent {
turn_seq: number;
learner_text_excerpt?: string | null;
suggestion: {
title: string;
message: string;
};
}
interface LiveCoachHistoryResponse {
source: string;
events: LiveCoachEvent[];
}
interface VoiceHealthResponse {
available?: boolean;
}
interface VoiceWsProbe {
code: number;
events: Array<{ type?: string; state?: string; [key: string]: unknown }>;
binaryChunks: number;
}
interface VoiceUiProbeMessage {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
}
interface VoiceUiProbeState {
getUserMediaCalls: number;
workletModuleLoads: number;
workletNodes: number;
workletChunks: number;
trackStops: number;
messages: VoiceUiProbeMessage[];
}
interface EvaluationSummaryResponse {
session_id: string;
status?: string | null;
error?: string | null;
durable?: boolean;
deep?: Record<string, unknown> | null;
}
interface PrepostMeasureItem {
measure_name: string;
timepoint: string;
raw_score: number;
normalized_score: number;
}
interface PrepostMeasuresResponse {
source: string;
durable: boolean;
pilot_id: string;
complete_measure_pairs: number;
measures: PrepostMeasureItem[];
}
async function expectResponseOk(response: { ok: () => boolean; text: () => Promise<string> }) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function setupSyntheticVoiceUiProbe(page: Page, transcript: string) {
await page.addInitScript((text) => {
type ProbeMessage = {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
};
type ProbeState = {
getUserMediaCalls: number;
workletModuleLoads: number;
workletNodes: number;
workletChunks: number;
trackStops: number;
messages: ProbeMessage[];
};
const probe: ProbeState = {
getUserMediaCalls: 0,
workletModuleLoads: 0,
workletNodes: 0,
workletChunks: 0,
trackStops: 0,
messages: [],
};
(window as Window & { __voiceUiProbe?: ProbeState }).__voiceUiProbe = probe;
const fakeTrack = {
kind: "audio",
enabled: true,
readyState: "live",
stop: () => {
probe.trackStops += 1;
fakeTrack.readyState = "ended";
},
};
const fakeStream = {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => {
probe.getUserMediaCalls += 1;
return fakeStream;
},
},
});
const NativeWebSocket = window.WebSocket;
const sizeOf = (data: unknown) => {
if (typeof data === "string") return data.length;
if (data instanceof Blob) return data.size;
if (data instanceof ArrayBuffer) return data.byteLength;
if (ArrayBuffer.isView(data)) return data.byteLength;
return 0;
};
class ProbeWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) super(url);
else super(url, protocols);
this.addEventListener("message", (event) => {
if (typeof event.data === "string") {
probe.messages.push({ direction: "received", kind: "text", data: event.data });
} else {
probe.messages.push({
direction: "received",
kind: "binary",
byteLength: sizeOf(event.data),
});
}
});
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data === "string") {
probe.messages.push({ direction: "sent", kind: "text", data });
try {
const parsed = JSON.parse(data) as {
type?: string;
silence_ms?: number;
barge_in?: boolean;
provider_events?: unknown[];
};
if (parsed.type === "audio_end") {
super.send(
JSON.stringify({
type: "stt_result",
text,
final: true,
silence_ms: parsed.silence_ms,
barge_in: parsed.barge_in,
provider_events: parsed.provider_events,
}),
);
return;
}
} catch {
// Keep non-JSON WebSocket traffic unchanged.
}
} else {
probe.messages.push({ direction: "sent", kind: "binary", byteLength: sizeOf(data) });
}
super.send(data);
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: ProbeWebSocket,
});
class FakeWorkletPort {
onmessage: ((event: MessageEvent) => void) | null = null;
closed = false;
postMessage(message: { type?: string }) {
if (message?.type === "flush") this.emitChunk();
}
close() {
this.closed = true;
}
emitChunk() {
if (this.closed) return;
probe.workletChunks += 1;
const pcm = new Int16Array([0, 4096, -4096, 0]);
this.onmessage?.({
data: {
type: "chunk",
pcm: pcm.buffer,
metrics: {
durationMs: 1600,
voiceMs: 360,
silenceMs: 1240,
trailingSilenceMs: 1240,
rms: 0.1,
peak: 0.72,
},
},
} as MessageEvent);
}
}
class FakeAudioWorkletNode {
port = new FakeWorkletPort();
constructor(_ctx: unknown, _name: string, _options?: unknown) {
probe.workletNodes += 1;
}
connect() {
return this;
}
disconnect() {
return undefined;
}
__start() {
window.setTimeout(() => this.port.emitChunk(), 25);
}
}
class FakeMediaStreamSource {
connect(node: { __start?: () => void }) {
node.__start?.();
return node;
}
disconnect() {
return undefined;
}
}
class FakeAudioContext {
state = "running";
destination = {};
sampleRate = 16000;
audioWorklet = {
addModule: async (_url: string) => {
probe.workletModuleLoads += 1;
},
};
async resume() {
this.state = "running";
}
async close() {
this.state = "closed";
}
async decodeAudioData(_data: ArrayBuffer) {
return { duration: 0.12 };
}
createAnalyser() {
return {
fftSize: 0,
frequencyBinCount: 4,
getByteTimeDomainData: (array: Uint8Array) => array.fill(128),
connect: () => undefined,
disconnect: () => undefined,
};
}
createBufferSource() {
return {
buffer: null,
connect: () => undefined,
disconnect: () => undefined,
start: () => undefined,
stop: () => undefined,
onended: null as (() => void) | null,
};
}
createMediaStreamSource(_stream: unknown) {
return new FakeMediaStreamSource();
}
}
Object.defineProperty(window, "AudioWorkletNode", {
configurable: true,
value: FakeAudioWorkletNode,
});
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: FakeAudioContext,
});
Object.defineProperty(window, "webkitAudioContext", {
configurable: true,
value: FakeAudioContext,
});
}, transcript);
}
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
return page.evaluate(() => {
const probe = (window as Window & { __voiceUiProbe?: VoiceUiProbeState }).__voiceUiProbe;
if (!probe) throw new Error("voice UI probe was not installed");
return {
...probe,
messages: [...probe.messages],
};
});
}
async function createSessionWithTurn(page: Page) {
const persona = await fetchAvailablePersona(page);
const startedResponse = await page.request.post("/api/sessions", {
@ -50,8 +401,391 @@ async function createSessionWithTurn(page: Page) {
return { sessionId: started.session_id, expectedMinTurns };
}
async function createEndedSessionWithoutTurn(page: Page) {
const persona = await fetchAvailablePersona(page);
const startedResponse = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(startedResponse);
const started = (await startedResponse.json()) as SessionStartResponse;
expect(started.degraded).toBe(false);
const endedResponse = await page.request.post(`/api/sessions/${started.session_id}/end`);
await expectResponseOk(endedResponse);
return started.session_id;
}
async function evaluationState(page: Page, sessionId: string) {
const response = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`);
if (!response.ok()) {
return `http:${response.status()}:${(await response.text()).slice(0, 120)}`;
}
const body = (await response.json()) as EvaluationSummaryResponse;
const loop = typeof body.deep?.loop === "string" ? body.deep.loop : "none";
const turnsEvaluated =
typeof body.deep?.turns_evaluated === "number" ? body.deep.turns_evaluated : 0;
if (body.status === "ready" && body.durable === true && loop === "deep") {
return `ready:${turnsEvaluated}`;
}
if (body.status === "error") {
return `error:${body.error || "empty-error"}:${body.durable ? "durable" : "cache"}`;
}
return `pending:${body.status ?? "none"}:${body.durable ? "durable" : "cache"}:${loop}`;
}
test.describe("session persistence", () => {
test("persists the browser SSE stream into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "real stream persistence requires DB and engine");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const learnerText =
"지금 많이 버거웠겠어요. 오늘 제일 크게 남아 있는 감정은 무엇인가요?";
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const streamResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
);
});
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
const streamResponse = await streamResponsePromise;
await expectResponseOk(streamResponse);
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
timeout: 90_000,
});
const clientUtterance = page.locator(".sx-utt.is-client").first();
await expect(clientUtterance).toBeVisible();
await expect(clientUtterance).not.toContainText("답변을 준비 중입니다.");
await expect(input).toBeEnabled({ timeout: 90_000 });
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.session_id).toBe(sessionId);
expect(review.turns.some((turn) => turn.speaker === "learner" && turn.text === learnerText)).toBe(
true,
);
expect(
review.turns.some((turn) => turn.speaker === "client" && turn.text.trim().length > 0),
).toBe(true);
});
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
test.setTimeout(150_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "real live-coach persistence requires DB and engine");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const learnerText =
"그 말을 꺼내는 것도 쉽지 않았겠어요. 지금 가장 버거운 감정부터 천천히 말해볼까요?";
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const streamResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/stream`)
);
});
const coachResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/api/sessions/${sessionId}/live-coach`)
);
});
const input = page.getByLabel("학습자 발화 입력");
await input.fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
const streamResponse = await streamResponsePromise;
await expectResponseOk(streamResponse);
await expect(page.locator(".sx-utt.is-client.is-thinking")).toHaveCount(0, {
timeout: 90_000,
});
await expect(input).toBeEnabled({ timeout: 90_000 });
const coachResponse = await coachResponsePromise;
await expectResponseOk(coachResponse);
const coachSuggestion = (await coachResponse.json()) as LiveCoachEvent["suggestion"];
await expect(page.locator(".sx-coach-card").getByText(coachSuggestion.title)).toBeVisible({
timeout: 30_000,
});
const historyResponse = await page.request.get(`/api/sessions/${sessionId}/live-coach`);
await expectResponseOk(historyResponse);
const history = (await historyResponse.json()) as LiveCoachHistoryResponse;
expect(history.source).toBe("database");
expect(
history.events.some(
(event) =>
event.turn_seq === 1 &&
event.learner_text_excerpt?.includes("가장 버거운 감정") &&
event.suggestion.title === coachSuggestion.title,
),
).toBe(true);
await page.goto(`/learn/session/${sessionId}`);
await expect(page.locator(".sx-utt.is-learner").filter({ hasText: learnerText })).toBeVisible({
timeout: 15_000,
});
await expect(page.locator(".sx-utt.is-learner .sx-utt__coach-mark")).toBeVisible({
timeout: 15_000,
});
});
test("persists voice nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(120_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
const voiceHealthResponse = await page.request.get("/api/voice/health");
await expectResponseOk(voiceHealthResponse);
const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse;
test.skip(
!health.db || !health.engine || !voiceHealth.available,
"voice nonverbal persistence requires DB, engine, and voice service",
);
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const startedResponse = await page.request.post("/api/sessions", {
data: {
persona_code: persona.code,
theory_mode: "humanistic",
},
});
await expectResponseOk(startedResponse);
const started = (await startedResponse.json()) as SessionStartResponse;
expect(started.degraded).toBe(false);
await page.goto(`/learn/session/${started.session_id}`);
const learnerText = "요즘 잠을 잘 못 자요.";
const probe = await page.evaluate(
({ sessionId, text }) =>
new Promise<VoiceWsProbe>((resolve) => {
const wsUrl = new URL(
`/api/voice/ws?session_id=${encodeURIComponent(sessionId)}`,
window.location.href,
);
wsUrl.protocol = wsUrl.protocol === "https:" ? "wss:" : "ws:";
const ws = new WebSocket(wsUrl.href);
ws.binaryType = "arraybuffer";
const events: VoiceWsProbe["events"] = [];
let binaryChunks = 0;
let sawReply = false;
const finish = (code: number) => {
window.clearTimeout(timeout);
resolve({ code, events, binaryChunks });
};
const timeout = window.setTimeout(() => {
ws.close();
finish(-1);
}, 60_000);
ws.onopen = () => {
ws.send(JSON.stringify({ type: "audio_start", format: "webm" }));
window.setTimeout(() => {
ws.send(
JSON.stringify({
type: "stt_result",
text,
final: true,
silence_ms: 1500,
barge_in: true,
provider_events: [{ type: "sigh", confidence: 0.82, text: "drop raw" }],
}),
);
}, 150);
};
ws.onmessage = (event) => {
if (typeof event.data === "string") {
const parsed = JSON.parse(event.data) as { type?: string; state?: string };
events.push(parsed);
if (parsed.type === "reply") sawReply = true;
if (sawReply && parsed.type === "state" && parsed.state === "idle") {
ws.send(JSON.stringify({ type: "close" }));
}
return;
}
binaryChunks += 1;
};
ws.onerror = () => {
events.push({ type: "error", detail: "browser websocket error" });
};
ws.onclose = (event) => finish(event.code);
}),
{ sessionId: started.session_id, text: learnerText },
);
expect(probe.code, JSON.stringify(probe, null, 2)).toBe(1000);
expect(probe.events.some((event) => event.type === "error" || event.type === "degraded")).toBe(
false,
);
expect(probe.events).toEqual(
expect.arrayContaining([
expect.objectContaining({ type: "eot", ready: true }),
expect.objectContaining({ type: "transcript", text: learnerText }),
expect.objectContaining({ type: "reply" }),
]),
);
const reviewResponse = await page.request.get(`/api/sessions/${started.session_id}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
const learnerTurn = review.turns.find(
(turn) => turn.speaker === "learner" && turn.text === learnerText,
);
expect(learnerTurn).toBeTruthy();
const nonverbalKinds = learnerTurn?.nonverbal?.map((event) => event.kind) ?? [];
expect(nonverbalKinds).toEqual(
expect.arrayContaining(["silence", "pace", "barge_in", "paralinguistic"]),
);
expect(learnerTurn?.nonverbal?.some((event) => event.label === "음성 단서")).toBe(true);
expect(learnerTurn?.nonverbal?.map((event) => event.detail).join(" ")).not.toContain(
"drop raw",
);
});
test("persists browser mic UI nonverbal metadata into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(150_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
const voiceHealthResponse = await page.request.get("/api/voice/health");
await expectResponseOk(voiceHealthResponse);
const voiceHealth = (await voiceHealthResponse.json()) as VoiceHealthResponse;
test.skip(
!health.db || !health.engine || !voiceHealth.available,
"browser mic UI voice persistence requires DB, engine, and voice service",
);
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const learnerText = "요즘 잠을 잘 못 자요.";
await setupSyntheticVoiceUiProbe(page, learnerText);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
const sessionId = new URL(page.url()).pathname.split("/").at(-1);
expect(sessionId).toMatch(/^[0-9a-f-]+$/i);
const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled();
await mic.click();
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).workletChunks, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_start"') &&
message.data?.includes('"format":"pcm"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await mic.click();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) => message.direction === "sent" && message.kind === "binary",
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_end"') &&
message.data?.includes('"silence_ms":1240') &&
message.data?.includes('"browser_audio_worklet"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
const events = probe.messages
.filter((message) => message.direction === "received" && message.kind === "text")
.map((message) => JSON.parse(message.data ?? "{}") as { type?: string; text?: string });
return {
transcript: events.some((event) => event.type === "transcript" && event.text === learnerText),
reply: events.some((event) => event.type === "reply"),
errors: events.filter((event) => event.type === "error" || event.type === "degraded").length,
};
}, { timeout: 60_000 })
.toEqual({ transcript: true, reply: true, errors: 0 });
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
const learnerTurn = review.turns.find(
(turn) => turn.speaker === "learner" && turn.text === learnerText,
);
expect(learnerTurn).toBeTruthy();
const nonverbal = learnerTurn?.nonverbal ?? [];
expect(nonverbal.map((event) => event.kind)).toEqual(expect.arrayContaining(["silence", "pace"]));
expect(nonverbal.filter((event) => event.kind === "silence")).toHaveLength(1);
expect(nonverbal.some((event) => event.label === "침묵" && event.detail === "1.2초")).toBe(
true,
);
expect(nonverbal.map((event) => event.detail).join(" ")).not.toContain("browser_audio_worklet");
});
test("persists learner turns into DB-backed review and teacher dashboard @single-run", async ({ page }) => {
test.setTimeout(120_000);
await signInAsLearner(page);
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
@ -92,4 +826,177 @@ test.describe("session persistence", () => {
),
).toBe(true);
});
test("persists learner case worksheet and teacher worksheet review through DB @single-run", async ({
page,
}) => {
test.setTimeout(150_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "case worksheet E2E requires DB and engine-backed turn");
await signInAsLearner(page);
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
expect(
expectedMinTurns,
"case worksheet E2E requires a persisted learner/client transcript",
).toBeGreaterThanOrEqual(2);
await page.goto(`/learn/session/${sessionId}/review`);
const worksheetCard = page.locator(".sr-card--worksheet");
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("축어록 기반 자동 초안");
const worksheetInputs = worksheetCard.locator(".sr-ws-input");
await expect(worksheetInputs.first()).toBeVisible();
const savedWorksheetValue = `E2E 저장 사례개념화 ${Date.now()}`;
await worksheetInputs.first().fill(savedWorksheetValue);
await worksheetCard.getByRole("button", { name: "저장" }).click();
await expect(worksheetCard).toContainText("저장된 학습자 제출본", { timeout: 15_000 });
const learnerReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(learnerReviewResponse);
const learnerReview = (await learnerReviewResponse.json()) as SessionReviewResponse;
expect(learnerReview.caseWorksheet?.status).toBe("saved_by_learner");
const learnerWorksheetValues = (learnerReview.caseWorksheet?.sections ?? []).flatMap((section) =>
(section.items ?? []).map((item) => item.value ?? ""),
);
expect(learnerWorksheetValues).toContain(savedWorksheetValue);
await signInAsTeacher(page);
await page.goto(`/teach/session/${sessionId}/review`);
await expect(worksheetCard).toBeVisible({ timeout: 20_000 });
await expect(worksheetCard).toContainText("학습자 저장본 읽기 전용");
await expect(worksheetCard.locator(".sr-ws-input").first()).toHaveValue(savedWorksheetValue);
const teacherWorksheetNote = `보호요인 보강 요청 ${Date.now()}`;
await page.getByLabel("워크시트 검수 메모").fill(teacherWorksheetNote);
await page.getByRole("button", { name: "수정요청" }).click();
await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("수정요청", {
timeout: 15_000,
});
await expect(page.locator(".sr-teacher-review__worksheet")).toContainText("워크시트 검수 시각");
const teacherReviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(teacherReviewResponse);
const teacherReview = (await teacherReviewResponse.json()) as SessionReviewResponse;
expect(teacherReview.caseWorksheet?.status).toBe("saved_by_learner");
expect(teacherReview.teacherReview?.worksheetStatus).toBe("changes_requested");
expect(teacherReview.teacherReview?.worksheetNote).toBe(teacherWorksheetNote);
const teacherWorksheetValues = (teacherReview.caseWorksheet?.sections ?? []).flatMap((section) =>
(section.items ?? []).map((item) => item.value ?? ""),
);
expect(teacherWorksheetValues).toContain(savedWorksheetValue);
});
test("persists Phase 3 pre/post pilot scores through the DB-backed review UI @single-run", async ({
page,
}) => {
test.setTimeout(90_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "pre/post pilot score persistence requires DB");
await signInAsLearner(page);
const sessionId = await createEndedSessionWithoutTurn(page);
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
const inputs = page.locator(".sr-card--prepost input");
await expect(inputs).toHaveCount(6);
const current = await inputs.evaluateAll((nodes) =>
nodes.map((node) => (node as HTMLInputElement).value),
);
const primaryValues = ["4.9", "4.7", "3.9", "4.1", "2.9", "3.8"];
const alternateValues = ["4.8", "4.6", "3.8", "4.2", "2.8", "3.7"];
const targetValues =
current.join("|") === primaryValues.join("|") ? alternateValues : primaryValues;
for (let index = 0; index < targetValues.length; index += 1) {
await inputs.nth(index).fill(targetValues[index]);
}
await expect(page.locator(".sr-prepost__actions")).toContainText("6개 변경");
await page.getByRole("button", { name: "점수 저장" }).click();
await expect(page.getByText("3/3 쌍")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
await expect(
page.getByText("효과 판정과 통계 검정은 평가설계 확정 후 별도 산출합니다."),
).toBeVisible();
const prepostResponse = await page.request.get(
"/api/users/me/prepost-measures?pilot_id=phase3-pilot-draft",
);
await expectResponseOk(prepostResponse);
const prepost = (await prepostResponse.json()) as PrepostMeasuresResponse;
expect(prepost.source).toBe("database");
expect(prepost.durable).toBe(true);
expect(prepost.complete_measure_pairs).toBe(3);
const byKey = new Map(
prepost.measures.map((measure) => [
`${measure.measure_name}:${measure.timepoint}`,
measure.raw_score,
]),
);
expect(byKey.get("self_efficacy:pre")).toBe(Number(targetValues[0]));
expect(byKey.get("self_efficacy:post")).toBe(Number(targetValues[1]));
expect(byKey.get("skill_proficiency:pre")).toBe(Number(targetValues[2]));
expect(byKey.get("skill_proficiency:post")).toBe(Number(targetValues[3]));
expect(byKey.get("training_satisfaction:pre")).toBe(Number(targetValues[4]));
expect(byKey.get("training_satisfaction:post")).toBe(Number(targetValues[5]));
await page.reload();
await expect(page.locator(".sr-card--prepost")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("3/3 쌍")).toBeVisible();
for (let index = 0; index < targetValues.length; index += 1) {
await expect(inputs.nth(index)).toHaveValue(targetValues[index]);
}
});
test("finishes session end evaluation into a durable DB-backed teacher review @single-run", async ({
page,
}) => {
test.setTimeout(240_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db || !health.engine, "session end evaluation requires DB and engine");
await signInAsLearner(page);
const { sessionId, expectedMinTurns } = await createSessionWithTurn(page);
expect(
expectedMinTurns,
"session end evaluation smoke requires a completed learner/client turn",
).toBeGreaterThanOrEqual(2);
await signInAsTeacher(page);
await expect
.poll(async () => evaluationState(page, sessionId), {
timeout: 180_000,
intervals: [1_000, 2_000, 5_000, 10_000],
message: "session end background evaluation should create a durable ready row",
})
.toMatch(/^ready:[1-9]\d*$/);
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
expect(review.reviewReady).toBe(true);
expect(review.degraded).toBe(false);
expect(review.supervisorState).toBe("평가 완료");
expect(review.summary ?? "").toContain("평가 AI");
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", {
timeout: 15_000,
});
await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨");
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
});
});

View file

@ -1,10 +1,15 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import {
FILLED_REVIEW_SESSION_ID,
filledReviewResponse,
routeFilledSessionReview,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
import {
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
} from "./support";
interface SessionStartResponse {
session_id: string;
@ -16,6 +21,31 @@ async function expectResponseOk(response: { ok: () => boolean; text: () => Promi
}
}
async function routeSupervisorCapableLearner(page: Page) {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "00000000-0000-0000-0000-000000000303",
email: "yunchan@twentyoz.kr",
role: "learner",
display_name: "Yun Chan",
admin_access: true,
super_admin: true,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "Yun Chan",
self_introduction: "",
avatar_url: "",
}),
});
});
}
function isReviewResponse(sessionId: string) {
return (response: Response) => {
const url = new URL(response.url());
@ -102,6 +132,243 @@ async function expectReviewRegionsReachable(page: Page) {
}
test.describe("session review", () => {
test("redirects supervisor-capable users from learner 403 into the teacher review route", async ({
page,
}) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
const sessionId = "supervisor-redirect-review";
let reviewAttempts = 0;
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
reviewAttempts += 1;
const referer = route.request().headers().referer ?? page.url();
if (!referer.includes(`/teach/session/${sessionId}/review`)) {
await route.fulfill({
status: 403,
contentType: "application/json",
body: JSON.stringify({ detail: "session does not belong to user" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(filledReviewResponse(sessionId)),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
});
test("normalizes a successful supervisor review response away from the learner route", async ({
page,
}) => {
await routeSupervisorCapableLearner(page);
const sessionId = "supervisor-success-review";
let reviewAttempts = 0;
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
reviewAttempts += 1;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...filledReviewResponse(sessionId),
teacherReview: {
status: "pending",
note: "",
reviewedAt: null,
reviewerId: null,
updatedAt: null,
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
},
}),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await expect(page.locator(".sr-card--prepost")).toHaveCount(0);
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
});
test("refreshes an ended review from evaluation pending to ready", async ({ page }) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
const sessionId = "review-pending-then-ready";
let reviewAttempts = 0;
let firstResponseWasPending = false;
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
reviewAttempts += 1;
const ready = {
...filledReviewResponse(sessionId),
summary: "평가 AI가 저장된 축어록을 분석했습니다.",
};
const pending = {
...ready,
supervisorState: "평가 대기",
summary: "평가 AI가 저장된 축어록을 분석 중입니다.",
rubric: [],
goodMoments: [],
growthPoints: [],
degraded: true,
reviewReady: false,
};
if (reviewAttempts === 1) firstResponseWasPending = true;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(reviewAttempts === 1 ? pending : ready),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible({
timeout: 5000,
});
await expect(page.getByText("평가 완료")).toBeVisible();
expect(firstResponseWasPending).toBe(true);
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
});
test("lets supervisor retry a failed AI session evaluation", async ({ page }) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
const sessionId = "review-failed-retry";
let reevaluateRequests = 0;
let retrySucceeded = false;
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
const ready = filledReviewResponse(sessionId);
const failed = {
...ready,
supervisorState: "평가 실패",
summary:
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: engine_error: evaluator timeout",
rubric: [],
goodMoments: [],
growthPoints: [],
degraded: true,
reviewReady: false,
teacherReview: {
status: "pending",
note: "",
reviewedAt: null,
reviewerId: null,
updatedAt: null,
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
},
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(retrySucceeded ? ready : failed),
});
});
await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, async (route) => {
reevaluateRequests += 1;
retrySucceeded = true;
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
loop: "deep",
session_id: sessionId,
stage: "정리",
scope: "session_end",
turns_evaluated: 2,
distribution: {},
theory_mode: "humanistic",
strengths: [],
improvements: [],
intent_deviations: [],
error: null,
}),
});
});
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("평가 실패")).toBeVisible();
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
await expect(page.getByText("평가 완료")).toBeVisible();
await expect(page.getByText("평가 실패")).toHaveCount(0);
expect(reevaluateRequests).toBe(1);
});
test("keeps the failed review visible when AI evaluation retry fails", async ({ page }) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
const sessionId = "review-failed-retry-error";
let reviewRequests = 0;
let reevaluateRequests = 0;
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
reviewRequests += 1;
const ready = filledReviewResponse(sessionId);
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...ready,
supervisorState: "평가 실패",
summary:
"저장된 축어록은 확인했지만 평가 AI 산출물이 아직 준비되지 않았습니다. 사유: session evaluation timeout after 45s",
rubric: [],
goodMoments: [],
growthPoints: [],
degraded: true,
reviewReady: false,
teacherReview: {
status: "pending",
note: "",
reviewedAt: null,
reviewerId: null,
updatedAt: null,
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
},
}),
});
});
await page.route(`**/api/eval/sessions/${sessionId}/reevaluate`, async (route) => {
reevaluateRequests += 1;
await route.fulfill({
status: 503,
contentType: "application/json",
body: JSON.stringify({ detail: "engine_error: retry timeout" }),
});
});
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("평가 실패")).toBeVisible();
const reviewRequestsBeforeRetry = reviewRequests;
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
await expect(page.getByText("engine_error: retry timeout")).toBeVisible();
await expect(page.getByText("평가 실패")).toBeVisible();
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeEnabled();
expect(reevaluateRequests).toBe(1);
expect(reviewRequests).toBe(reviewRequestsBeforeRetry);
});
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page);
await routePrepostMeasures(page);

View file

@ -518,6 +518,141 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("shows selected learner analysis with the full session timeline @single-run", async ({
page,
}) => {
const learnerA = "teacher-analysis-learner-a";
const learnerB = "teacher-analysis-learner-b";
const sessionFor = (learnerId: string, label: string, index: number) => ({
session_id: `${learnerId}-session-${index}`,
learner_id: learnerId,
learner_label: label,
persona_code: index % 2 === 0 ? "P2" : "P1",
persona_name: index % 2 === 0 ? "민재" : "서연",
session_no: index,
status: "ended",
stage: index > 4 ? "개입" : "탐색",
turn_count: 8 + index,
learner_turn_count: 4 + index,
client_turn_count: 4,
started_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
ended_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
review_status: index === 2 ? "closed" : "pending",
review_note: null,
reviewed_at: index === 2 ? "2026-06-12T10:00:00Z" : null,
});
const pointFor = (learnerId: string, index: number) => ({
session_id: `${learnerId}-session-${index}`,
session_no: index,
persona_code: index % 2 === 0 ? "P2" : "P1",
stage: index > 4 ? "개입" : "탐색",
started_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:00:00Z`,
ended_at: `2026-06-${String(10 + index).padStart(2, "0")}T09:30:00Z`,
score: index >= 5 ? 1 : 0.5,
rapport: Math.min(0.8, index * 0.1),
technique_count: index + 1,
watch_count: index < 5 ? 1 : 0,
});
const analysisFor = (learnerId: string, label: string, count: number) => {
const sessions = Array.from({ length: count }, (_unused, idx) =>
sessionFor(learnerId, label, idx + 1),
);
const points = Array.from({ length: count }, (_unused, idx) =>
pointFor(learnerId, idx + 1),
);
return {
source: "database",
learner_id: learnerId,
learner_label: label,
total_sessions: count,
active_sessions: 0,
ended_sessions: count,
pending_reviews: Math.max(0, count - 1),
closed_reviews: count > 1 ? 1 : 0,
summary: {
learner_id: learnerId,
learner_label: label,
sessions: count,
ended_sessions: count,
latest_at: sessions[count - 1].ended_at ?? "",
first_score: points[0].score,
latest_score: points[count - 1].score,
score_delta: (points[count - 1].score ?? 0) - (points[0].score ?? 0),
avg_score: 0.75,
avg_rapport: 0.4,
trend: count > 3 ? "up" : "flat",
top_techniques: ["reflection", "open question"],
points,
},
points,
stage_breakdown: [
{ stage: "라포", sessions: 0, turns: 0 },
{ stage: "탐색", sessions: Math.min(4, count), turns: 34 },
{ stage: "개입", sessions: Math.max(0, count - 4), turns: 42 },
{ stage: "정리", sessions: 0, turns: 0 },
],
sessions,
message: `${label} 전체 회기 ${count}`,
};
};
const analysisA = analysisFor(learnerA, "사용자 A", 7);
const analysisB = analysisFor(learnerB, "사용자 B", 2);
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 2,
active_sessions: 0,
ended_sessions: 9,
learner_growth: [
{ ...analysisA.summary, points: analysisA.points.slice(-6) },
{ ...analysisB.summary, points: analysisB.points },
],
safety_alerts: [],
pending_reviews: [],
recent_sessions: analysisA.sessions.slice(-2),
message: "사용자별 분석 fixture.",
}),
}),
);
await page.route("**/api/teacher/learners/*/analysis", (route) => {
const path = new URL(route.request().url()).pathname;
const body = path.includes(learnerB) ? analysisB : analysisA;
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(body),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: "[]" }),
);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.getByRole("link", { name: "학생 분석" })).toBeVisible();
await expect(page.locator('[data-learner-analysis-panel="true"]')).toHaveCount(0);
await page.getByRole("link", { name: "학생 분석" }).click();
await expect(page).toHaveURL(/\/teach\/analysis$/);
const analysisPanel = page.locator('[data-learner-analysis-panel="true"]');
await expect(analysisPanel).toBeVisible();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(7);
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toBeVisible();
await expect(page.getByText("전체 회기 7건")).toBeVisible();
await page.getByRole("button", { name: "사용자 B 사용자별 분석" }).click();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2);
await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible();
await expect(analysisPanel.getByText(`${learnerA}-session-7`)).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("opens a pending session review from the teacher queue @single-run", async ({ page }) => {
const sessionId = "teacher-review-fixture-session";
await page.route("**/api/teacher/dashboard", (route) =>

View file

@ -587,7 +587,20 @@ async function installSyntheticVoiceCapture(
if (this.closed) return;
probe.workletChunks += 1;
const pcm = new Int16Array([0, 1024, -1024, 0]);
this.onmessage?.({ data: { type: "chunk", pcm: pcm.buffer } } as MessageEvent);
this.onmessage?.({
data: {
type: "chunk",
pcm: pcm.buffer,
metrics: {
durationMs: 1600,
voiceMs: 350,
silenceMs: 1250,
trailingSilenceMs: 1250,
rms: 0.08,
peak: 0.64,
},
},
} as MessageEvent);
}
}
@ -1001,6 +1014,40 @@ test.describe("voice cascade success path", () => {
);
}, { timeout: 10_000 })
.toBeTruthy();
const sentAudioEndControls = (await readVoiceUiProbe(page)).messages
.filter(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_end"'),
)
.map((message) => JSON.parse(message.data ?? "{}") as Record<string, unknown>);
expect(
sentAudioEndControls.some((control) => {
const events = Array.isArray(control.provider_events) ? control.provider_events : [];
return (
control.format === "pcm" &&
control.silence_ms === 1250 &&
control.barge_in === false &&
events.some((event) => {
const record = event as Record<string, unknown>;
return (
record.type === "voice_activity" &&
record.source === "browser_audio_worklet" &&
record.duration_ms === 350
);
}) &&
events.some((event) => {
const record = event as Record<string, unknown>;
return (
record.type === "silence" &&
record.source === "browser_audio_worklet" &&
record.duration_ms === 1250
);
})
);
}),
).toBeTruthy();
await expect
.poll(async () => {

View file

@ -4,6 +4,13 @@ class VoiceCaptureProcessor extends AudioWorkletProcessor {
this.pending = [];
this.pendingLength = 0;
this.flushSamples = Math.max(1024, Math.floor(sampleRate * 0.25));
this.totalSamples = 0;
this.voiceSamples = 0;
this.silentSamples = 0;
this.trailingSilentSamples = 0;
this.sumSquares = 0;
this.peak = 0;
this.voiceThreshold = 0.018;
this.port.onmessage = (event) => {
if (event.data && event.data.type === "flush") {
this.flush();
@ -19,6 +26,17 @@ class VoiceCaptureProcessor extends AudioWorkletProcessor {
const pcm = new Int16Array(channel.length);
for (let i = 0; i < channel.length; i += 1) {
const sample = Math.max(-1, Math.min(1, channel[i]));
const abs = Math.abs(sample);
this.totalSamples += 1;
this.sumSquares += sample * sample;
if (abs > this.peak) this.peak = abs;
if (abs >= this.voiceThreshold) {
this.voiceSamples += 1;
this.trailingSilentSamples = 0;
} else {
this.silentSamples += 1;
this.trailingSilentSamples += 1;
}
pcm[i] = sample < 0 ? sample * 0x8000 : sample * 0x7fff;
}
@ -46,11 +64,28 @@ class VoiceCaptureProcessor extends AudioWorkletProcessor {
sampleRate,
channels: 1,
sampleWidth: 2,
metrics: this.metrics(),
pcm: merged.buffer,
},
[merged.buffer],
);
}
metrics() {
const durationMs = (this.totalSamples / sampleRate) * 1000;
const voiceMs = (this.voiceSamples / sampleRate) * 1000;
const silenceMs = (this.silentSamples / sampleRate) * 1000;
const trailingSilenceMs = (this.trailingSilentSamples / sampleRate) * 1000;
const rms = this.totalSamples > 0 ? Math.sqrt(this.sumSquares / this.totalSamples) : 0;
return {
durationMs,
voiceMs,
silenceMs,
trailingSilenceMs,
rms,
peak: this.peak,
};
}
}
registerProcessor("voice-capture-processor", VoiceCaptureProcessor);

View file

@ -6,6 +6,7 @@
/learn/session/:sessionId -> Session
/learn/session/:sessionId/review -> SessionReview
/teach -> Professor (teacher data-role=instructor)
/teach/analysis -> Professor (teacher learner analysis)
/teach/personas -> PersonaStudio (teacher/admin)
/teach/session/:sessionId/review -> SessionReview (teacher read-only)
/admin -> Admin (admin)
@ -209,6 +210,14 @@ function AppRoutes() {
</RequireAuth>
}
/>
<Route
path="/teach/analysis"
element={
<RequireAuth roles={["teacher"]}>
<Professor view="analysis" />
</RequireAuth>
}
/>
<Route
path="/teach/personas"
element={

View file

@ -23,6 +23,7 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
],
teacher: [
{ to: "/teach", label: "콘솔", icon: "users", end: true },
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
{ to: "/teach/personas", label: "페르소나", icon: "review" },
{ to: "/settings", label: "설정", icon: "settings" },
],
@ -32,6 +33,7 @@ const NAV_BY_ROLE: Record<Role, NavItem[]> = {
{ to: "/admin/access", label: "권한", icon: "settings" },
{ to: "/admin/tickets", label: "티켓", icon: "review" },
{ to: "/teach", label: "교수 콘솔", icon: "users", end: true },
{ to: "/teach/analysis", label: "학생 분석", icon: "review" },
{ to: "/teach/personas", label: "페르소나", icon: "review" },
{ to: "/learn", label: "학습자 홈", icon: "home", end: true },
{ to: "/learn/practice", label: "학습", icon: "session" },

View file

@ -1144,6 +1144,26 @@ export interface paths {
patch?: never;
trace?: never;
};
"/teacher/learners/{learner_id}/analysis": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Learner Analysis
* @description Return one learner's full teacher-visible session timeline and trend.
*/
get: operations["learner_analysis_teacher_learners__learner_id__analysis_get"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/teacher/sessions/{session_id}/review-status": {
parameters: {
query?: never;
@ -1987,10 +2007,19 @@ export interface components {
deep?: Record<string, never> | null;
/** Distribution */
distribution?: Record<string, never>;
/**
* Durable
* @default false
*/
durable: boolean;
/** Error */
error?: string | null;
/** Session Id */
session_id: string;
/** Stage */
stage?: ("라포" | "탐색" | "개입" | "정리") | null;
/** Status */
status?: string | null;
};
/** HTTPValidationError */
HTTPValidationError: {
@ -3600,6 +3629,37 @@ export interface components {
*/
watch_count: number;
};
/** TeacherLearnerAnalysisResponse */
TeacherLearnerAnalysisResponse: {
/** Active Sessions */
active_sessions: number;
/** Closed Reviews */
closed_reviews: number;
/** Ended Sessions */
ended_sessions: number;
/** Learner Id */
learner_id: string;
/** Learner Label */
learner_label: string;
/** Message */
message: string;
/** Pending Reviews */
pending_reviews: number;
/** Points */
points?: components["schemas"]["TeacherGrowthPoint"][];
/** Sessions */
sessions?: components["schemas"]["TeacherSessionSummary"][];
/**
* Source
* @default in_memory
*/
source: string;
/** Stage Breakdown */
stage_breakdown?: components["schemas"]["TeacherStageBreakdown"][];
summary: components["schemas"]["TeacherLearnerGrowth"];
/** Total Sessions */
total_sessions: number;
};
/** TeacherLearnerGrowth */
TeacherLearnerGrowth: {
/** Avg Rapport */
@ -3760,6 +3820,24 @@ export interface components {
/** Turn Count */
turn_count: number;
};
/** TeacherStageBreakdown */
TeacherStageBreakdown: {
/**
* Sessions
* @default 0
*/
sessions: number;
/**
* Stage
* @enum {string}
*/
stage: "라포" | "탐색" | "개입" | "정리";
/**
* Turns
* @default 0
*/
turns: number;
};
/**
* TechniqueDistribution
* @description deep-loop + / .
@ -6303,6 +6381,40 @@ export interface operations {
};
};
};
learner_analysis_teacher_learners__learner_id__analysis_get: {
parameters: {
query?: never;
header?: never;
path: {
learner_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"]["TeacherLearnerAnalysisResponse"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_session_review_status_teacher_sessions__session_id__review_status_put: {
parameters: {
query?: never;

View file

@ -205,6 +205,8 @@ export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
export type ReevaluateRequest = ApiSchema<"ReevaluateRequest">;
export type SessionEvaluationResponse = ApiSchema<"SessionEvaluationResponse">;
/** POST /sessions/{id}/end — sessions.py SessionEndResponse */
export type SessionEndResponse = ApiSchema<"SessionEndResponse">;
@ -474,6 +476,17 @@ export const sessionApi = {
stream: openSessionStream,
};
export const evalApi = {
reevaluateSession: (
sessionId: string,
payload: ReevaluateRequest = { scope: "session_end" },
) =>
api.post<SessionEvaluationResponse>(
`/eval/sessions/${encodeURIComponent(sessionId)}/reevaluate`,
payload,
),
};
export type AdminHealthStatus = ApiSchema<"AdminHealthResponse">["status"];
export type AdminServiceHealth = ApiSchema<"AdminServiceHealth">;
export type AdminHealthResponse = ApiSchema<"AdminHealthResponse">;
@ -547,14 +560,20 @@ export type TeacherSessionSummary = ApiSchema<"TeacherSessionSummary">;
export type TeacherSafetyAlert = ApiSchema<"TeacherSafetyAlert">;
export type TeacherGrowthPoint = ApiSchema<"TeacherGrowthPoint">;
export type TeacherStageBreakdown = ApiSchema<"TeacherStageBreakdown">;
export type TeacherLearnerGrowth = ApiSchema<"TeacherLearnerGrowth">;
export type TeacherLearnerAnalysisResponse = ApiSchema<"TeacherLearnerAnalysisResponse">;
export type TeacherDashboardResponse = ApiSchema<"TeacherDashboardResponse">;
export type TeacherSessionReviewStatusRequest = ApiSchema<"TeacherSessionReviewStatusRequest">;
export type TeacherSessionReviewStatusResponse = ApiSchema<"TeacherSessionReviewStatusResponse">;
export const teacherApi = {
dashboard: () => api.get<TeacherDashboardResponse>("/teacher/dashboard"),
learnerAnalysis: (learnerId: string) =>
api.get<TeacherLearnerAnalysisResponse>(
`/teacher/learners/${encodeURIComponent(learnerId)}/analysis`,
),
updateSessionReviewStatus: (
sessionId: string,
body: TeacherSessionReviewStatusRequest,

View file

@ -9,14 +9,17 @@ import {
type PersonaReviewAction,
type PersonaReviewStatus,
type PersonaReviewSummary,
type TeacherLearnerAnalysisResponse,
type TeacherLearnerGrowth,
type TeacherGrowthPoint,
type TeacherSafetyAlert,
type TeacherDashboardResponse,
type TeacherSessionSummary,
} from "../lib/api";
import "./professor.css";
type LoadState = "loading" | "ready" | "error";
type ProfessorView = "console" | "analysis";
function formatDateTime(value: string | null): string {
if (!value) return "-";
@ -41,6 +44,11 @@ function formatDelta(value: number | null | undefined): string {
return `${sign}${Math.round(value * 100)}%p`;
}
function formatRapport(value: number | null | undefined): string {
if (typeof value !== "number" || Number.isNaN(value)) return "라포 부족";
return `${Math.round(((value + 1) / 2) * 100)}%`;
}
function trendLabel(value: string): string {
if (value === "up") return "상승";
if (value === "down") return "하락";
@ -85,16 +93,23 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
</div>
);
}
export default function Professor() {
export default function Professor({ view = "console" }: { view?: ProfessorView }) {
const navigate = useNavigate();
const isAnalysisView = view === "analysis";
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
const [personaReviews, setPersonaReviews] = useState<PersonaReviewSummary[]>([]);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
const [personaReviewLoading, setPersonaReviewLoading] = useState(true);
const [personaReviewLoading, setPersonaReviewLoading] = useState(!isAnalysisView);
const [personaReviewError, setPersonaReviewError] = useState<string | null>(null);
const [personaDecisionBusy, setPersonaDecisionBusy] = useState<string | null>(null);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);
const [selectedLearnerId, setSelectedLearnerId] = useState<string | null>(null);
const [learnerAnalysis, setLearnerAnalysis] =
useState<TeacherLearnerAnalysisResponse | null>(null);
const [learnerAnalysisState, setLearnerAnalysisState] = useState<LoadState>("ready");
const [learnerAnalysisError, setLearnerAnalysisError] = useState<string | null>(null);
const [learnerAnalysisReloadSeq, setLearnerAnalysisReloadSeq] = useState(0);
const loadDashboard = useCallback(async () => {
setLoadState("loading");
@ -154,8 +169,14 @@ export default function Professor() {
useEffect(() => {
void loadDashboard();
void loadPersonaReviews();
}, [loadDashboard, loadPersonaReviews]);
if (isAnalysisView) {
setPersonaReviews([]);
setPersonaReviewLoading(false);
setPersonaReviewError(null);
} else {
void loadPersonaReviews();
}
}, [isAnalysisView, loadDashboard, loadPersonaReviews]);
const kpis = useMemo(
() => [
@ -209,23 +230,89 @@ export default function Professor() {
const pendingReviews = dashboard?.pending_reviews ?? [];
const recentSessions = dashboard?.recent_sessions ?? [];
const firstPendingReview = pendingReviews[0];
const selectedLearner = learnerGrowth.find((learner) => learner.learner_id === selectedLearnerId);
const isRefreshing =
loadState === "loading" ||
(!isAnalysisView && personaReviewLoading) ||
(isAnalysisView && learnerAnalysisState === "loading");
useEffect(() => {
if (!isAnalysisView) return;
if (learnerGrowth.length === 0) {
if (selectedLearnerId !== null) setSelectedLearnerId(null);
return;
}
if (!selectedLearnerId || !learnerGrowth.some((learner) => learner.learner_id === selectedLearnerId)) {
setSelectedLearnerId(learnerGrowth[0].learner_id);
}
}, [isAnalysisView, learnerGrowth, selectedLearnerId]);
useEffect(() => {
if (!isAnalysisView) {
setLearnerAnalysis(null);
setLearnerAnalysisState("ready");
setLearnerAnalysisError(null);
return;
}
if (!selectedLearnerId) {
setLearnerAnalysis(null);
setLearnerAnalysisState("ready");
setLearnerAnalysisError(null);
return;
}
let active = true;
setLearnerAnalysisState("loading");
setLearnerAnalysisError(null);
void teacherApi
.learnerAnalysis(selectedLearnerId)
.then((next) => {
if (!active) return;
setLearnerAnalysis(next);
setLearnerAnalysisState("ready");
})
.catch((err) => {
if (!active) return;
setLearnerAnalysis(null);
setLearnerAnalysisError(
err instanceof Error ? err.message : "학습자 분석을 불러오지 못했습니다.",
);
setLearnerAnalysisState("error");
});
return () => {
active = false;
};
}, [isAnalysisView, selectedLearnerId, learnerAnalysisReloadSeq]);
return (
<AppShell navRole="teacher" wide>
<main className="pf-root">
<main className={`pf-root ${isAnalysisView ? "pf-root--analysis" : "pf-root--console"}`}>
<header className="pf-head">
<div>
<Kicker> </Kicker>
<h1>
{hasPending ? `${pendingCount}건의 리뷰가 대기 중입니다.` : "검토할 실제 회기가 없습니다."}
</h1>
<p>
. ,
.
</p>
<Kicker>{isAnalysisView ? "학생 분석" : "교수 콘솔"}</Kicker>
{isAnalysisView ? (
<>
<h1> .</h1>
<p>
, ,
.
</p>
</>
) : (
<>
<h1>
{hasPending ? `${pendingCount}건의 리뷰가 대기 중입니다.` : "검토할 실제 회기가 없습니다."}
</h1>
<p>
.
.
</p>
</>
)}
</div>
<div className="pf-head__actions">
{firstPendingReview ? (
{!isAnalysisView && firstPendingReview ? (
<Button
leading={<Icon name="review" size={16} />}
onClick={() => navigate(`/teach/session/${firstPendingReview.session_id}/review`)}
@ -233,20 +320,42 @@ export default function Professor() {
</Button>
) : null}
{isAnalysisView ? (
<Button
variant="secondary"
leading={<Icon name="users" size={16} />}
onClick={() => navigate("/teach")}
>
</Button>
) : (
<Button
variant="secondary"
leading={<Icon name="users" size={16} />}
onClick={() => navigate("/teach/analysis")}
>
</Button>
)}
<Button
variant="secondary"
leading={<Icon name="review" size={16} />}
onClick={() => {
void loadDashboard();
void loadPersonaReviews();
if (isAnalysisView) {
setLearnerAnalysisReloadSeq((seq) => seq + 1);
} else {
void loadPersonaReviews();
}
}}
disabled={loadState === "loading" || personaReviewLoading}
disabled={isRefreshing}
>
{loadState === "loading" || personaReviewLoading ? "확인 중" : "새로고침"}
{isRefreshing ? "확인 중" : "새로고침"}
</Button>
</div>
</header>
{!isAnalysisView ? (
<section className="pf-signal-strip" aria-label="교수자 검토 요약">
<section className={`pf-triage ${hasPending ? "is-active" : ""}`} role="status">
<Dot tone={loadState === "error" ? "crit" : "accent"} size={9} />
@ -289,6 +398,7 @@ export default function Professor() {
})}
</section>
</section>
) : null}
{error ? (
<section className="pf-error" role="alert">
@ -297,11 +407,14 @@ export default function Professor() {
</section>
) : null}
{isAnalysisView ? (
<section className="pf-analysis-shell" aria-label="학생 분석 작업면">
<aside className="pf-analysis-rail">
<section className="pf-section pf-section--growth">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>· </h2>
<Kicker> </Kicker>
<h2> </h2>
</div>
<Badge tone={learnerGrowth.length > 0 ? "accent" : "neutral"}>
{learnerGrowth.length}
@ -312,7 +425,12 @@ export default function Professor() {
{learnerGrowth.length > 0 ? (
<div className="pf-growth-list">
{learnerGrowth.map((learner) => (
<GrowthCard learner={learner} key={learner.learner_id} />
<GrowthCard
learner={learner}
key={learner.learner_id}
selected={learner.learner_id === selectedLearnerId}
onSelect={() => setSelectedLearnerId(learner.learner_id)}
/>
))}
</div>
) : (
@ -323,7 +441,73 @@ export default function Professor() {
)}
</Card>
</section>
</aside>
<section className="pf-analysis-workbench">
<nav className="pf-depth-strip" aria-label="학생 분석 단계">
<span className="is-current">
<small>1</small>
</span>
<span>
<small>2</small>
</span>
<span>
<small>3</small>
</span>
<span>
<small>4</small>
</span>
</nav>
<section className="pf-section pf-section--learner-analysis">
<div className="pf-section__head">
<div>
<Kicker> </Kicker>
<h2>{selectedLearner?.learner_label ?? "학습자 이력"}</h2>
</div>
<Badge tone={learnerAnalysis ? "accent" : "neutral"}>
{learnerAnalysis ? `${learnerAnalysis.total_sessions}회기` : "대기"}
</Badge>
</div>
<Card className="pf-panel pf-analysis-panel">
{learnerGrowth.length === 0 ? (
<EmptyState
title={loadState === "loading" ? "이력 계산 중" : "분석할 학습자 없음"}
desc="실제 학습자 회기가 쌓이면 사용자별 전체 이력을 표시합니다."
/>
) : learnerAnalysisState === "loading" ? (
<EmptyState
title="학습자 이력 계산 중"
desc="회기별 점수, 단계, 검토 상태를 불러오고 있습니다."
/>
) : learnerAnalysisError ? (
<div className="pf-empty" role="alert">
<Icon name="alert" size={18} />
<b> </b>
<span>{learnerAnalysisError}</span>
</div>
) : learnerAnalysis ? (
<LearnerAnalysisPanel
analysis={learnerAnalysis}
onOpenSession={(sessionId) => navigate(`/teach/session/${sessionId}/review`)}
/>
) : (
<EmptyState
title="선택된 학습자 없음"
desc="실제 회기 기반 사용자별 분석이 이 영역에 표시됩니다."
/>
)}
</Card>
</section>
</section>
</section>
) : null}
{!isAnalysisView ? (
<section className="pf-workspace">
<div className="pf-queue-stack">
<section className="pf-section pf-section--pending">
@ -608,17 +792,197 @@ export default function Professor() {
</Card>
</section>
</section>
) : null}
</main>
</AppShell>
);
}
function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
function LearnerAnalysisPanel({
analysis,
onOpenSession,
}: {
analysis: TeacherLearnerAnalysisResponse;
onOpenSession: (sessionId: string) => void;
}) {
const points = analysis.points ?? [];
const sessions = analysis.sessions ?? [];
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
const stageBreakdown = analysis.stage_breakdown ?? [];
const summary = analysis.summary;
return (
<div className="pf-analysis" data-learner-analysis-panel="true">
<div className="pf-analysis__top">
<div className="pf-analysis__title">
<b>{analysis.learner_label}</b>
<span>{analysis.message}</span>
</div>
<div className="pf-analysis__metrics" aria-label="사용자별 분석 요약">
<span>
<small> </small>
<b>{analysis.total_sessions}</b>
</span>
<span>
<small>/</small>
<b>
{analysis.ended_sessions}/{analysis.active_sessions}
</b>
</span>
<span>
<small> </small>
<b>{analysis.pending_reviews}</b>
</span>
<span>
<small> </small>
<b>{formatScore(summary.latest_score)}</b>
</span>
<span>
<small></small>
<b>{formatDelta(summary.score_delta)}</b>
</span>
<span>
<small> </small>
<b>{formatRapport(summary.avg_rapport)}</b>
</span>
</div>
</div>
<div className="pf-analysis__body">
<section className="pf-analysis__chart" aria-label="회기별 추이">
<div className="pf-analysis__subhead">
<b> </b>
<span>{trendLabel(summary.trend)}</span>
</div>
{points.length > 0 ? (
<div className="pf-analysis-bars">
{points.map((point) => (
<GrowthBar point={point} key={point.session_id} />
))}
</div>
) : (
<EmptyState title="회기별 점수 없음" desc="평가가 저장된 턴이 아직 없습니다." />
)}
</section>
<section className="pf-analysis__stages" aria-label="단계 분포">
<div className="pf-analysis__subhead">
<b> </b>
<span>{analysis.total_sessions}</span>
</div>
<div className="pf-stage-list">
{stageBreakdown.map((item) => (
<div className="pf-stage-item" key={item.stage}>
<span>{item.stage}</span>
<b>{item.sessions}</b>
<small>{item.turns}</small>
</div>
))}
</div>
</section>
</div>
<section className="pf-analysis__timeline" aria-label="전체 회기 타임라인">
<div className="pf-analysis__subhead">
<b> </b>
<span>{sessions.length}</span>
</div>
{sessions.length > 0 ? (
<div className="pf-timeline">
{sessions.map((session) => (
<LearnerSessionRow
session={session}
point={pointBySession.get(session.session_id)}
key={session.session_id}
onOpen={() => onOpenSession(session.session_id)}
/>
))}
</div>
) : (
<EmptyState title="회기 없음" desc="표시할 회기 기록이 없습니다." />
)}
</section>
</div>
);
}
function LearnerSessionRow({
session,
point,
onOpen,
}: {
session: TeacherSessionSummary;
point: TeacherGrowthPoint | undefined;
onOpen: () => void;
}) {
return (
<button
type="button"
className="pf-timeline-row"
data-learner-session-row="true"
aria-label={`${session.learner_label} ${session.persona_code} ${session.session_no}회기 ${sessionOpenLabel(session.status)}`}
onClick={onOpen}
>
<span className="pf-timeline-row__step">{session.session_no}</span>
<div className="pf-timeline-row__main">
<b>
{session.persona_code} · {session.persona_name}
</b>
<span>
{session.stage} · {session.turn_count} · {session.learner_turn_count} / {" "}
{session.client_turn_count}
</span>
<code>{session.session_id}</code>
</div>
<div className="pf-timeline-row__metrics">
<span>
<small></small>
<b>{formatScore(point?.score)}</b>
</span>
<span>
<small></small>
<b>{formatRapport(point?.rapport)}</b>
</span>
<span>
<small></small>
<Badge tone={sessionReviewTone(session.review_status)}>
{sessionReviewStatusLabel(session.review_status)}
</Badge>
</span>
</div>
<div className="pf-timeline-row__dates">
<span>{formatDateTime(session.started_at)}</span>
<span>{formatDateTime(session.ended_at ?? null)}</span>
</div>
<span className="pf-timeline-row__open">
<Icon name="review" size={13} />
{sessionOpenLabel(session.status)}
</span>
</button>
);
}
function GrowthCard({
learner,
selected,
onSelect,
}: {
learner: TeacherLearnerGrowth;
selected: boolean;
onSelect: () => void;
}) {
const points = learner.points ?? [];
const topTechniques = learner.top_techniques ?? [];
const recentPoints = points.slice(-3).reverse();
return (
<article className={`pf-growth-card trend-${learner.trend}`}>
<button
type="button"
className={`pf-growth-card trend-${learner.trend} ${selected ? "is-selected" : ""}`}
data-learner-growth-card="true"
aria-pressed={selected}
aria-label={`${learner.learner_label} 사용자별 분석`}
onClick={onSelect}
>
<div className="pf-growth-card__top">
<div className="pf-growth-card__id">
<b>{learner.learner_label}</b>
@ -679,7 +1043,7 @@ function GrowthCard({ learner }: { learner: TeacherLearnerGrowth }) {
</div>
)}
</div>
</article>
</button>
);
}

View file

@ -86,12 +86,22 @@ interface VoiceEvent {
type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
type VoiceCaptureMode = "audio-worklet" | "media-recorder";
interface VoiceProviderEvent {
type: string;
source: string;
duration_ms?: number;
confidence?: number;
}
interface VoiceCaptureControl {
type: "audio_start" | "audio_end";
format: string;
sample_rate?: number;
channels?: number;
sample_width?: number;
silence_ms?: number;
barge_in?: boolean;
provider_events?: VoiceProviderEvent[];
}
interface VoiceCaptureController {
@ -106,6 +116,16 @@ interface VoiceCaptureController {
interface VoiceWorkletMessage {
type?: string;
pcm?: ArrayBuffer;
metrics?: VoiceCaptureMetrics;
}
interface VoiceCaptureMetrics {
durationMs?: number;
voiceMs?: number;
silenceMs?: number;
trailingSilenceMs?: number;
rms?: number;
peak?: number;
}
interface Utterance {
@ -446,6 +466,59 @@ function sendVoiceControl(ws: WebSocket, payload: VoiceCaptureControl): void {
}
}
function roundedNonnegativeMs(value: number | undefined): number | undefined {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return undefined;
return Math.max(0, Math.round(value));
}
function voiceActivityConfidence(metrics: VoiceCaptureMetrics): number | undefined {
const peak = typeof metrics.peak === "number" && Number.isFinite(metrics.peak) ? metrics.peak : 0;
if (peak <= 0) return undefined;
return Math.max(0.01, Math.min(0.99, Number(peak.toFixed(2))));
}
function voiceProviderEventsFromMetrics(
metrics: VoiceCaptureMetrics | null,
source: string,
): VoiceProviderEvent[] {
if (!metrics) return [];
const durationMs = roundedNonnegativeMs(metrics.durationMs);
const voiceMs = roundedNonnegativeMs(metrics.voiceMs);
const trailingSilenceMs = roundedNonnegativeMs(metrics.trailingSilenceMs);
const events: VoiceProviderEvent[] = [];
if (voiceMs !== undefined) {
const activity: VoiceProviderEvent = {
type: "voice_activity",
source,
duration_ms: voiceMs,
};
const confidence = voiceActivityConfidence(metrics);
if (confidence !== undefined) activity.confidence = confidence;
events.push(activity);
} else if (durationMs !== undefined) {
events.push({ type: "voice_activity", source, duration_ms: durationMs });
}
if (trailingSilenceMs !== undefined && trailingSilenceMs >= 1000) {
events.push({ type: "silence", source, duration_ms: trailingSilenceMs });
}
return events;
}
function audioEndControlWithMetrics(
base: VoiceCaptureControl,
metrics: VoiceCaptureMetrics | null,
source: string,
): VoiceCaptureControl {
const silenceMs = roundedNonnegativeMs(metrics?.trailingSilenceMs);
const providerEvents = voiceProviderEventsFromMetrics(metrics, source);
return {
...base,
...(silenceMs !== undefined ? { silence_ms: silenceMs } : {}),
barge_in: false,
...(providerEvents.length ? { provider_events: providerEvents } : {}),
};
}
function supportsAudioWorkletCapture(ctx: AudioContext | null): ctx is AudioContext & {
audioWorklet: AudioWorklet;
} {
@ -477,6 +550,7 @@ function createMediaRecorderCapture(
let finishRequested = false;
let released = false;
let startedAt = 0;
const release = () => {
if (released) return;
released = true;
@ -489,13 +563,20 @@ function createMediaRecorderCapture(
};
recorder.onstop = () => {
release();
if (finishRequested) sendVoiceControl(ws, endControl);
if (finishRequested) {
const durationMs = startedAt > 0 ? performance.now() - startedAt : undefined;
sendVoiceControl(
ws,
audioEndControlWithMetrics(endControl, { durationMs, voiceMs: durationMs }, "browser_media_recorder"),
);
}
};
return {
mode: "media-recorder",
startControl,
start: () => {
startedAt = performance.now();
recorder.start(500);
},
finish: () => {
@ -509,7 +590,11 @@ function createMediaRecorderCapture(
}
}
release();
sendVoiceControl(ws, endControl);
const durationMs = startedAt > 0 ? performance.now() - startedAt : undefined;
sendVoiceControl(
ws,
audioEndControlWithMetrics(endControl, { durationMs, voiceMs: durationMs }, "browser_media_recorder"),
);
},
abort: () => {
finishRequested = false;
@ -549,6 +634,7 @@ async function createAudioWorkletCapture(
let recording = false;
let released = false;
let finishTimer: number | null = null;
let latestMetrics: VoiceCaptureMetrics | null = null;
const release = () => {
if (released) return;
@ -576,6 +662,7 @@ async function createAudioWorkletCapture(
if (!recording || ws.readyState !== WebSocket.OPEN) return;
const payload = event.data;
if (payload?.type !== "chunk" || !payload.pcm?.byteLength) return;
latestMetrics = payload.metrics ?? latestMetrics;
ws.send(payload.pcm);
};
@ -597,7 +684,7 @@ async function createAudioWorkletCapture(
finishTimer = null;
recording = false;
release();
sendVoiceControl(ws, endControl);
sendVoiceControl(ws, audioEndControlWithMetrics(endControl, latestMetrics, "browser_audio_worklet"));
}, 20);
},
abort: () => {
@ -1135,11 +1222,13 @@ export default function Session() {
}, [acceptConsent, consentChecked, pushSignal]);
const appendServerClientReply = useCallback(
(replyText: string | null, at = elapsed) => {
(replyText: string | null, at = elapsed, options?: { suppressEmptyWarning?: boolean }) => {
setClientReplyPending(false);
if (!replyText) {
setAvatarState("listening");
pushSignal("warn", "내담자 응답 없음");
if (!options?.suppressEmptyWarning) {
pushSignal("warn", "내담자 응답 없음");
}
return;
}
setUtterances((prev) => [
@ -1180,6 +1269,7 @@ export default function Session() {
let clientId: number | null = null;
let clientReply = "";
let conversationStopped = false;
try {
const done = await sessionApi.stream(liveSessionId, text, {
onToken: (chunk) => {
@ -1210,6 +1300,7 @@ export default function Session() {
setSafety("방금 발화에서 위기 신호가 감지됐어요. 안전을 우선해 머물러 주세요.");
}
if (data.conversation_stopped || data.crisis_resource) {
conversationStopped = true;
applyCrisisGate(data.crisis_resource);
}
},
@ -1218,10 +1309,16 @@ export default function Session() {
if (payload && typeof payload === "object") {
const resource = (payload as { crisis_resource?: CrisisResource }).crisis_resource;
const stopped = (payload as { conversation_stopped?: boolean }).conversation_stopped;
if (resource || stopped) applyCrisisGate(resource);
if (resource || stopped) {
conversationStopped = true;
applyCrisisGate(resource);
}
}
},
});
if (done.conversation_stopped || done.crisis_resource) {
conversationStopped = true;
}
if (clientId != null) {
const id = clientId;
@ -1247,14 +1344,18 @@ export default function Session() {
setUtterances((prev) =>
prev.map((u) => (u.id === learnerId ? { ...u, turnSeq: done.turn_seq } : u)),
);
pushSignal("warn", "내담자 응답 없음");
if (!conversationStopped) {
pushSignal("warn", "내담자 응답 없음");
}
}
setAvatarState("listening");
void requestLiveCoach({
learnerText: text,
clientReply,
turnSeq: done.turn_seq,
});
if (!conversationStopped) {
void requestLiveCoach({
learnerText: text,
clientReply,
turnSeq: done.turn_seq,
});
}
} catch (err) {
setComposeText(text);
setClientReplyPending(false);
@ -1606,6 +1707,7 @@ export default function Session() {
if (payload.conversation_stopped || payload.crisis_resource) {
applyCrisisGate(payload.crisis_resource);
}
const conversationStopped = !!(payload.conversation_stopped || payload.crisis_resource);
const pendingId = pendingVoiceLearnerIdRef.current;
if (pendingId != null) {
setUtterances((prev) =>
@ -1615,10 +1717,12 @@ export default function Session() {
);
pendingVoiceLearnerIdRef.current = null;
}
appendServerClientReply(payload.text ?? null);
appendServerClientReply(payload.text ?? null, elapsed, {
suppressEmptyWarning: conversationStopped,
});
const learnerText = pendingVoiceLearnerTextRef.current;
pendingVoiceLearnerTextRef.current = "";
if (learnerText) {
if (learnerText && !conversationStopped) {
void requestLiveCoach({
learnerText,
clientReply: payload.text ?? null,
@ -1830,27 +1934,37 @@ export default function Session() {
endPreviousFocusRef.current = null;
}, [endDialogOpen, ending]);
// 회기 종료 — 명시 확인 후 end 호출, 현재 화면을 정지 상태로 둔다.
// 회기 종료 — 명시 확인 후 end 호출, 실패하면 리뷰 화면으로 넘어가지 않는다.
const handleEnd = useCallback(async () => {
if (ending || sessionEnded) return;
setEnding(true);
setTurnError(null);
try {
if (liveSessionId) await sessionApi.end(liveSessionId);
} catch {
/* 종료 실패는 UI 종료 처리를 막지 않는다. */
if (!liveSessionId) {
throw new Error("회기 ID가 없습니다.");
}
await sessionApi.end(liveSessionId);
} catch (err) {
const detail =
err instanceof ApiError
? err.detail
: err instanceof Error
? err.message
: "알 수 없는 오류";
setEndDialogOpen(false);
setTurnError(`회기 종료에 실패했습니다. ${detail}`);
pushSignal("warn", "회기 종료 실패");
setEnding(false);
return;
}
setEndDialogOpen(false);
setSessionEnded(true);
setReviewReady(true);
setReviewReady(false);
setMicOn(false);
setPaused(true);
setAvatarState("idle");
pushSignal("neutral", "회기 종료");
if (liveSessionId) {
navigate(`/learn/session/${liveSessionId}/review`, { replace: true });
} else {
setEnding(false);
}
navigate(`/learn/session/${liveSessionId}/review`, { replace: true });
}, [ending, liveSessionId, navigate, pushSignal, sessionEnded]);
const meters = metersFromOpenness(openness);
@ -1914,6 +2028,7 @@ export default function Session() {
primaryContext?.v ??
`${clientName}님이 당신의 첫 질문을 기다리고 있습니다.`;
const safetyStatusText = safety ? "확인 필요" : "안전";
const turnErrorTitle = turnError?.startsWith("회기 종료") ? "회기 종료" : "엔진 연결";
const personaIsUsable = personaSummary ? isUsablePersona(personaSummary) : false;
const consentRequired = user?.role === "learner" && user.consentAt == null;
const canStartSession = personaLoadState === "ready" && personaIsUsable && !consentRequired;
@ -2526,7 +2641,7 @@ export default function Session() {
<Icon name="alert" size={17} />
</span>
<span className="sx-safety__text">
<b> </b> · {turnError}
<b>{turnErrorTitle}</b> · {turnError}
</span>
</section>
) : null}

View file

@ -11,6 +11,8 @@ import {
ProgressBar,
} from "../components/ui";
import {
ApiError,
evalApi,
sessionApi,
teacherApi,
userApi,
@ -25,6 +27,7 @@ import {
type UserPrepostMeasureItem,
type UserPrepostMeasuresResponse,
} from "../lib/api";
import { canAccessRole, useAuth } from "../lib/auth";
import { ValenceChart } from "./session-review/ValenceChart";
import "./session-review/session-review.css";
@ -38,6 +41,8 @@ const PREPOST_PILOT_ID = "phase3-pilot-draft";
const PREPOST_INSTRUMENT_VERSION = "pilot-prepost-scaffold-2026-06-28";
const PREPOST_SCORE_MIN = 1;
const PREPOST_SCORE_MAX = 5;
const REVIEW_READY_POLL_INTERVAL_MS = 1500;
const REVIEW_READY_POLL_LIMIT = 8;
const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
@ -110,6 +115,15 @@ function renderSummary(summary: string) {
});
}
function shouldPollReviewReady(data: SessionReviewResponse) {
return (
!data.reviewReady &&
data.degraded &&
data.supervisorState === "평가 대기" &&
(data.turns?.length ?? 0) > 0
);
}
function renderInlineMarkdown(text: string): ReactNode[] {
const nodes: ReactNode[] = [];
let cursor = 0;
@ -624,6 +638,7 @@ function PrepostMeasureCard() {
autoComplete="off"
value={draft[key] ?? ""}
placeholder="-"
disabled={loading || saving}
aria-invalid={invalid}
aria-label={`${measure.label} ${timepoint.label} 점수`}
onChange={(event) => {
@ -674,6 +689,7 @@ export default function SessionReview() {
const { sessionId } = useParams<{ sessionId: string }>();
const navigate = useNavigate();
const location = useLocation();
const { user } = useAuth();
const [data, setData] = useState<SessionReviewResponse | null>(null);
const [loadState, setLoadState] = useState<LoadState>("loading");
const [error, setError] = useState<string | null>(null);
@ -688,6 +704,9 @@ export default function SessionReview() {
const [teacherWorksheetSaving, setTeacherWorksheetSaving] =
useState<WorksheetDecisionStatus | null>(null);
const [teacherReviewError, setTeacherReviewError] = useState<string | null>(null);
const [evaluationRetrying, setEvaluationRetrying] = useState(false);
const [evaluationRetryError, setEvaluationRetryError] = useState<string | null>(null);
const [reviewReloadSeq, setReviewReloadSeq] = useState(0);
const turnRefs = useRef<Record<string, HTMLDivElement | null>>({});
const handleWorksheetSaved = (worksheet: ReviewCaseWorksheet) => {
@ -699,6 +718,8 @@ export default function SessionReview() {
useEffect(() => {
let alive = true;
let pollTimer: number | null = null;
let pollAttempts = 0;
if (!sessionId) {
setError("세션 ID가 없습니다.");
setLoadState("error");
@ -707,28 +728,51 @@ export default function SessionReview() {
};
}
(async () => {
setLoadState("loading");
const loadReview = async () => {
if (pollAttempts === 0) setLoadState("loading");
setError(null);
try {
const next = await sessionApi.review(sessionId);
if (!alive) return;
if (!isSupervisorView && user && canAccessRole(user, "teacher") && next.teacherReview) {
navigate(`/teach/session/${encodeURIComponent(sessionId)}/review`, { replace: true });
return;
}
setData(next);
setTeacherNote(next.teacherReview?.note ?? "");
setTeacherWorksheetNote(next.teacherReview?.worksheetNote ?? "");
setTeacherReviewError(null);
setEvaluationRetryError(null);
setLoadState("ready");
if (shouldPollReviewReady(next) && pollAttempts < REVIEW_READY_POLL_LIMIT) {
pollAttempts += 1;
pollTimer = window.setTimeout(loadReview, REVIEW_READY_POLL_INTERVAL_MS);
}
} catch (err) {
if (!alive) return;
if (
err instanceof ApiError &&
err.status === 403 &&
err.detail === "session does not belong to user" &&
!isSupervisorView &&
user &&
canAccessRole(user, "teacher")
) {
navigate(`/teach/session/${encodeURIComponent(sessionId)}/review`, { replace: true });
return;
}
setError(err instanceof Error ? err.message : "회기 리뷰를 불러오지 못했습니다.");
setLoadState("error");
}
})();
};
void loadReview();
return () => {
alive = false;
if (pollTimer !== null) window.clearTimeout(pollTimer);
};
}, [sessionId]);
}, [isSupervisorView, navigate, reviewReloadSeq, sessionId, user]);
const visibleTurns = useMemo<ReviewTurn[]>(() => {
const turns = data?.turns ?? [];
@ -774,6 +818,30 @@ export default function SessionReview() {
}
}
async function handleRetryEvaluation() {
if (!sessionId || evaluationRetrying) return;
setEvaluationRetrying(true);
setEvaluationRetryError(null);
try {
const result = await evalApi.reevaluateSession(sessionId, { scope: "session_end" });
const resultError = typeof result.error === "string" ? result.error : "";
if (resultError) {
throw new Error(resultError);
}
setReviewReloadSeq((seq) => seq + 1);
} catch (err) {
setEvaluationRetryError(
err instanceof ApiError
? err.detail
: err instanceof Error
? err.message
: "AI 평가를 다시 실행하지 못했습니다.",
);
} finally {
setEvaluationRetrying(false);
}
}
async function saveTeacherReviewStatus(status: "viewed" | "closed") {
if (!sessionId || !data) return;
setTeacherReviewSaving(status);
@ -907,6 +975,7 @@ export default function SessionReview() {
const hasLearnerWorksheet = data.caseWorksheet?.status === "saved_by_learner";
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
const canDecideWorksheet = canCloseTeacherReview && hasLearnerWorksheet;
const canRetryEvaluation = canCloseTeacherReview && hasTranscript && !data.reviewReady;
const reviewReadiness = hasTranscript
? `${turns.length}개 발화 기반`
: "축어록 저장 후 생성";
@ -987,6 +1056,17 @@ export default function SessionReview() {
</Button>
) : null}
{canRetryEvaluation ? (
<Button
variant="primary"
size="sm"
leading={<Icon name="review" size={14} />}
disabled={evaluationRetrying}
onClick={handleRetryEvaluation}
>
{evaluationRetrying ? "AI 평가 실행 중" : "AI 평가 재시도"}
</Button>
) : null}
<Button
variant="primary"
size="sm"
@ -1031,6 +1111,9 @@ export default function SessionReview() {
) : null}
</>
) : null}
{evaluationRetryError ? (
<p className="sr-share-note sr-share-note--error">{evaluationRetryError}</p>
) : null}
</div>
</Card>

View file

@ -854,10 +854,12 @@
position:sticky;
top:calc(var(--topbar-h) + 18px);
display:grid;
grid-template-rows:auto minmax(0,1fr);
max-height:calc(100dvh - var(--topbar-h) - 36px);
grid-template-rows:auto auto;
align-content:start;
height:max-content;
max-height:none;
padding:var(--sp-4);
overflow:hidden;
overflow:visible;
}
.lh-pane-head{margin-bottom:var(--sp-3);}
.lh-pane-head button{
@ -889,26 +891,30 @@
min-height:0;
display:grid;
align-content:start;
gap:8px;
overflow:auto;
scrollbar-gutter:stable;
padding-right:3px;
grid-auto-rows:auto;
gap:10px;
overflow:visible;
scrollbar-gutter:auto;
padding-right:0;
}
.lh-persona{
position:relative;
min-width:0;
min-height:92px;
width:100%;
min-height:0;
height:auto;
display:grid;
grid-template-columns:44px minmax(0,1fr);
gap:11px;
align-items:start;
padding:12px 38px 12px 12px;
padding:13px 38px 13px 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
color:var(--text-body);
text-align:left;
cursor:pointer;
overflow:visible;
}
.lh-persona:hover{border-color:var(--border-strong);}
.lh-persona:disabled{
@ -936,6 +942,7 @@
.lh-persona__body{
min-width:0;
display:grid;
align-content:start;
gap:5px;
}
.lh-persona__name{
@ -962,10 +969,8 @@
color:var(--text-body);
font-size:12.5px;
line-height:1.45;
overflow:hidden;
display:-webkit-box;
-webkit-line-clamp:3;
-webkit-box-orient:vertical;
word-break:keep-all;
overflow-wrap:anywhere;
}
.lh-persona__check{
position:absolute;

View file

@ -5,6 +5,9 @@
flex-direction:column;
gap:16px;
}
.pf-root--analysis{
max-width:1440px;
}
.pf-head{
display:flex;
align-items:flex-end;
@ -171,13 +174,75 @@
line-height:1.35;
}
.pf-workspace{
order:2;
order:4;
display:grid;
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
}
.pf-analysis-shell{
order:2;
display:grid;
grid-template-columns:minmax(300px,360px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
}
.pf-analysis-rail{
position:sticky;
top:16px;
min-width:0;
}
.pf-analysis-workbench{
min-width:0;
display:flex;
flex-direction:column;
gap:10px;
}
.pf-depth-strip{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
min-width:0;
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-depth-strip span{
min-width:0;
display:flex;
align-items:center;
gap:7px;
padding:10px 12px;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-depth-strip span + span{
border-left:1px solid var(--hair);
}
.pf-depth-strip small{
width:22px;
height:22px;
display:grid;
place-items:center;
border-radius:var(--radius-sm);
color:var(--text-muted);
background:var(--bg-surface-2);
font-family:var(--font-num);
line-height:1;
}
.pf-depth-strip .is-current{
color:var(--text-strong);
background:color-mix(in srgb,var(--accent-tint) 36%,var(--bg-surface));
}
.pf-depth-strip .is-current small{
color:var(--text-on-accent);
background:var(--accent);
}
.pf-queue-stack{
display:flex;
flex-direction:column;
@ -250,6 +315,10 @@
justify-content:flex-end;
}
.pf-section--growth{
order:2;
min-width:0;
}
.pf-section--learner-analysis{
order:3;
min-width:0;
}
@ -265,7 +334,17 @@
gap:12px;
padding:12px;
}
.pf-analysis-rail .pf-growth-list{
max-height:min(690px,calc(100vh - 250px));
grid-template-columns:1fr;
gap:9px;
padding:10px;
}
.pf-growth-card{
width:100%;
font:inherit;
text-align:left;
color:inherit;
min-width:0;
display:flex;
flex-direction:column;
@ -275,6 +354,33 @@
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2));
box-shadow:none;
cursor:pointer;
}
.pf-growth-card:hover{
border-color:var(--accent);
background:var(--bg-surface);
}
.pf-growth-card:focus-visible{
outline:2px solid var(--accent);
outline-offset:2px;
}
.pf-growth-card.is-selected{
border-color:var(--accent);
background:color-mix(in srgb,var(--accent-tint) 34%,var(--bg-surface));
}
.pf-analysis-rail .pf-growth-card{
gap:9px;
padding:11px;
border-radius:var(--radius);
}
.pf-analysis-rail .pf-growth-card__points{
display:none;
}
.pf-analysis-rail .pf-growth-card__tags{
min-height:0;
}
.pf-analysis-rail .pf-growth-bars{
height:54px;
}
.pf-growth-card__top{
display:flex;
@ -410,6 +516,262 @@
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-analysis-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--bg-surface-2) 72%,var(--bg-surface)));
}
.pf-analysis{
min-width:0;
display:grid;
gap:12px;
padding:12px;
}
.pf-analysis__top{
min-width:0;
display:grid;
grid-template-columns:minmax(210px,.42fr) minmax(0,1fr);
gap:12px;
align-items:stretch;
}
.pf-analysis__title{
min-width:0;
display:grid;
align-content:center;
gap:5px;
padding:12px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.pf-analysis__title b{
color:var(--text-strong);
font-size:var(--fs-body);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-analysis__title span{
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.45;
overflow-wrap:anywhere;
}
.pf-analysis__metrics{
min-width:0;
display:grid;
grid-template-columns:repeat(6,minmax(0,1fr));
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
overflow:hidden;
}
.pf-analysis__metrics span{
min-width:0;
display:grid;
align-content:center;
gap:4px;
padding:10px 11px;
}
.pf-analysis__metrics span + span{
border-left:1px solid var(--hair);
}
.pf-analysis__metrics small,
.pf-stage-item small,
.pf-timeline-row__metrics small{
color:var(--text-muted);
font-size:11px;
line-height:1.2;
}
.pf-analysis__metrics b,
.pf-stage-item b,
.pf-timeline-row__metrics b{
min-width:0;
color:var(--text-strong);
font-family:var(--font-num);
font-size:15px;
line-height:1.15;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.pf-analysis__body{
min-width:0;
display:grid;
grid-template-columns:minmax(0,1.25fr) minmax(240px,.75fr);
gap:12px;
}
.pf-analysis__chart,
.pf-analysis__stages,
.pf-analysis__timeline{
min-width:0;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface) 84%,var(--bg-surface-2));
overflow:hidden;
}
.pf-analysis__subhead{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-width:0;
padding:10px 12px;
border-bottom:1px solid var(--hair);
}
.pf-analysis__subhead b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
}
.pf-analysis__subhead span{
color:var(--text-muted);
font-size:var(--fs-xs);
white-space:nowrap;
}
.pf-analysis-bars{
height:118px;
display:flex;
align-items:flex-end;
gap:7px;
padding:12px;
overflow-x:auto;
scrollbar-gutter:stable;
}
.pf-analysis-bars .pf-growth-bar{
flex:0 0 34px;
}
.pf-stage-list{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-stage-item{
min-width:0;
display:grid;
gap:4px;
padding:12px;
border-left:1px solid var(--hair);
}
.pf-stage-item:first-child{
border-left:0;
}
.pf-stage-item span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-timeline{
max-height:min(520px,58vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-timeline-row{
width:100%;
font:inherit;
text-align:left;
color:inherit;
background:transparent;
display:grid;
grid-template-columns:38px minmax(170px,1fr) minmax(210px,.82fr) minmax(112px,.42fr) auto;
gap:10px;
align-items:center;
padding:12px;
border:0;
border-top:1px solid var(--paper-2);
cursor:pointer;
}
.pf-timeline-row:first-child{
border-top:0;
}
.pf-timeline-row:hover{
background:var(--bg-surface-2);
}
.pf-timeline-row:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-timeline-row__step{
width:32px;
height:32px;
display:grid;
place-items:center;
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-family:var(--font-num);
font-weight:800;
}
.pf-timeline-row__main{
min-width:0;
display:grid;
gap:3px;
}
.pf-timeline-row__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-timeline-row__main span,
.pf-timeline-row__main code,
.pf-timeline-row__dates span{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
}
.pf-timeline-row__main span{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-timeline-row__main code{
font-family:var(--font-num);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-timeline-row__metrics{
min-width:0;
display:grid;
grid-template-columns:minmax(58px,.8fr) minmax(58px,.8fr) minmax(86px,1.12fr);
gap:7px;
}
.pf-timeline-row__metrics span{
min-width:0;
display:grid;
gap:3px;
}
.pf-timeline-row__metrics .vg-badge{
justify-self:start;
white-space:nowrap;
}
.pf-timeline-row__dates{
display:grid;
gap:3px;
min-width:0;
font-family:var(--font-num);
}
.pf-timeline-row__open{
display:inline-flex;
align-items:center;
justify-content:center;
gap:4px;
min-height:30px;
padding:0 8px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-timeline-row:hover .pf-timeline-row__open{
border-color:var(--accent);
}
.pf-empty{
min-height:118px;
display:grid;
@ -763,9 +1125,13 @@
}
@media (max-width:1100px){
.pf-signal-strip,
.pf-workspace{
.pf-workspace,
.pf-analysis-shell{
grid-template-columns:1fr;
}
.pf-analysis-rail{
position:static;
}
.pf-triage{
grid-template-columns:auto minmax(0,max-content) auto;
justify-content:start;
@ -778,6 +1144,33 @@
.pf-growth-list{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-analysis-rail .pf-growth-list{
max-height:360px;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-analysis__top{
grid-template-columns:1fr;
}
.pf-analysis__metrics{
grid-template-columns:repeat(3,minmax(0,1fr));
}
.pf-analysis__metrics span:nth-child(3n+1){
border-left:0;
}
.pf-analysis__metrics span:nth-child(n+4){
border-top:1px solid var(--hair);
}
.pf-analysis__body{
grid-template-columns:1fr;
}
.pf-timeline-row{
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
}
.pf-timeline-row__dates{
grid-column:2 / 4;
grid-row:2;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-list,
.pf-personas{
max-height:360px;
@ -791,6 +1184,15 @@
width:100%;
justify-content:flex-start;
}
.pf-depth-strip{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-depth-strip span:nth-child(odd){
border-left:0;
}
.pf-depth-strip span:nth-child(n+3){
border-top:1px solid var(--hair);
}
.pf-triage{
grid-template-columns:auto minmax(0,1fr);
}
@ -805,6 +1207,62 @@
.pf-growth-list{
grid-template-columns:1fr;
}
.pf-analysis-rail .pf-growth-list{
grid-template-columns:1fr;
}
.pf-analysis{
padding:10px;
}
.pf-analysis__metrics{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-analysis__metrics span,
.pf-analysis__metrics span:nth-child(3n+1){
border-left:0;
}
.pf-analysis__metrics span:nth-child(even){
border-left:1px solid var(--hair);
}
.pf-analysis__metrics span:nth-child(n+3){
border-top:1px solid var(--hair);
}
.pf-stage-list{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-stage-item,
.pf-stage-item:first-child{
border-left:0;
}
.pf-stage-item:nth-child(even){
border-left:1px solid var(--hair);
}
.pf-stage-item:nth-child(n+3){
border-top:1px solid var(--hair);
}
.pf-timeline{
max-height:560px;
}
.pf-timeline-row{
grid-template-columns:34px minmax(0,1fr);
align-items:start;
}
.pf-timeline-row__main{
grid-column:2;
}
.pf-timeline-row__metrics,
.pf-timeline-row__dates,
.pf-timeline-row__open{
grid-column:2;
}
.pf-timeline-row__metrics{
grid-template-columns:repeat(3,minmax(0,1fr));
}
.pf-timeline-row__dates{
grid-row:auto;
}
.pf-timeline-row__open{
justify-self:start;
}
.pf-recent-list{
display:flex;
flex-direction:column;
@ -877,12 +1335,50 @@
.pf-persona__actions .vg-btn{
width:100%;
}
.pf-depth-strip{
grid-template-columns:1fr;
}
.pf-depth-strip span,
.pf-depth-strip span:nth-child(odd){
border-left:0;
}
.pf-depth-strip span:nth-child(n+2){
border-top:1px solid var(--hair);
}
.pf-growth-list{
padding:10px;
}
.pf-growth-card__metrics{
grid-template-columns:1fr;
}
.pf-analysis__metrics{
grid-template-columns:1fr;
}
.pf-analysis__metrics span,
.pf-analysis__metrics span:nth-child(even),
.pf-analysis__metrics span:nth-child(3n+1){
border-left:0;
}
.pf-analysis__metrics span:nth-child(n+2){
border-top:1px solid var(--hair);
}
.pf-stage-list{
grid-template-columns:1fr;
}
.pf-stage-item,
.pf-stage-item:nth-child(even),
.pf-stage-item:first-child{
border-left:0;
}
.pf-stage-item:nth-child(n+2){
border-top:1px solid var(--hair);
}
.pf-timeline-row__metrics{
grid-template-columns:1fr;
}
.pf-timeline-row__dates{
grid-template-columns:1fr;
}
.pf-growth-point{
grid-template-columns:1fr;
gap:2px;

View file

@ -242,6 +242,54 @@
.sr-card--side {
align-self: start;
}
@media (min-width: 1181px) {
.sr-cols--supervisor {
grid-template-columns: minmax(0, 1fr) minmax(286px, 340px);
grid-template-areas: "main side";
align-items: start;
}
.sr-cols--supervisor .sr-left {
grid-area: main;
min-width: 0;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-template-areas:
"overview overview"
"chart flow"
"transcript transcript";
gap: var(--sp-4);
align-items: start;
}
.sr-cols--supervisor .sr-right {
grid-area: side;
min-width: 0;
display: grid;
gap: var(--sp-4);
align-items: start;
}
.sr-cols--supervisor .sr-left > *,
.sr-cols--supervisor .sr-right > * {
grid-area: auto;
}
.sr-cols--supervisor .sr-overview {
grid-area: overview;
position: static;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
}
.sr-cols--supervisor .sr-card--chart {
grid-area: chart;
}
.sr-cols--supervisor .sr-card--flow {
grid-area: flow;
}
.sr-cols--supervisor .sr-card--transcript {
grid-area: transcript;
align-self: start;
}
}
.sr-root--empty .sr-card--side {
padding-top: var(--sp-5);
padding-bottom: var(--sp-5);

View file

@ -3111,6 +3111,55 @@
.sx-page--active .sx-col-right {
display: none;
}
.sx-page--active.sx-feedback-coached .sx-grid {
grid-template-rows: auto minmax(0, 1fr) auto;
}
.sx-page--active.sx-feedback-coached .sx-col-right {
display: flex;
grid-column: 1;
grid-row: 3;
min-height: 0;
overflow: visible;
}
.sx-page--active.sx-feedback-coached .sx-col-right .sx-meters,
.sx-page--active.sx-feedback-coached .sx-col-right .sx-safety,
.sx-page--active.sx-feedback-coached .sx-signal__head,
.sx-page--active.sx-feedback-coached .sx-signal__one,
.sx-page--active.sx-feedback-coached .sx-signal__rest,
.sx-page--active.sx-feedback-coached .sx-signal__wave,
.sx-page--active.sx-feedback-coached .sx-signal__rows,
.sx-page--active.sx-feedback-coached .sx-signal__seq,
.sx-page--active.sx-feedback-coached .sx-signal__defer {
display: none;
}
.sx-page--active.sx-feedback-coached .sx-col-right .sx-signal {
padding: 8px;
overflow: visible;
}
.sx-page--active.sx-feedback-coached .sx-coach-card {
grid-template-columns: 34px minmax(0, 1fr);
gap: 8px;
margin-top: 0;
}
.sx-page--active.sx-feedback-coached .sx-coach-avatar {
width: 34px;
height: 34px;
}
.sx-page--active.sx-feedback-coached .sx-coach-avatar__face {
left: 9px;
right: 9px;
top: 12px;
}
.sx-page--active.sx-feedback-coached .sx-coach-bubble {
padding: 8px 10px;
gap: 5px;
}
.sx-page--active.sx-feedback-coached .sx-coach-bubble blockquote {
display: none;
}
.sx-page--active.sx-feedback-coached .sx-coach-bubble__actions {
display: none;
}
.sx-head {
flex-direction: column;
align-items: flex-start;