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

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;

View file

@ -145,6 +145,18 @@
"forbidden_substrings": [],
"required_substrings": ["학교", "엄마", "친구"]
},
{
"id": "ko_career_topic_false_positive",
"locale": "ko-KR",
"source": "synthetic",
"category": "negative_control",
"severity": "medium",
"text": "아직 정해진 건 없어요. 진로는 그대로고 엄마한테 말도 못 했어요.",
"expected_entities": [],
"unexpected_entities": ["NAME"],
"forbidden_substrings": [],
"required_substrings": ["진로는 그대로고"]
},
{
"id": "ko_address_negative_control",
"locale": "ko-KR",

View file

@ -1,6 +1,6 @@
# Vignette Handoff
> Updated: 2026-06-28 11:05 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
> Updated: 2026-07-01 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
## 현재 상태
@ -10,10 +10,10 @@
- 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099`
- 공개 웹: `https://vignette.chanpaca.net`
- 공개 API: `https://api-vignette.chanpaca.net`
- 최신 앱 배포 소스 commit: `0ec266a` (`동의 게이트와 런타임 안정화`)
- 최신 Cloudflare Pages production deploy: 2026-06-29 manual deploy, branch `main`, custom domain assets `assets/index-9KEDuuBQ.js` + `assets/index-DC83qEpU.css`.
- 최신 앱 배포 소스: 2026-07-01 `origin/master` manual deploy snapshot.
- 최신 Cloudflare Pages production deploy: 2026-07-01 manual deploy, branch `main`, custom domain assets `assets/index-B5dHolQ4.js` + `assets/index-PBfLFh-m.css`.
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
- 최신 백엔드 회귀: `python -m pytest app/ -q` → `130 passed`
- 최신 백엔드 회귀: `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m pytest app/ -q` → `305 passed`
- X1 재귀학습 export 1차: `scripts/export-recursive-dataset.py` 기본 read-only dry-run, `--write-dataset` 명시 시에만 `ds.*` write, approved export는 steward/legal/IAA gate 없으면 거부.
- C1 사례개념화 워크시트 2차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 편집 카드가 축어록 근거 기반 초안을 제공한다. 학습자 저장본은 `PUT /sessions/{id}/review/worksheet``app.case_worksheet`에 저장되고, 이후 `GET /review``saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. 검증: focused backend 25 passed, API typecheck/build, 로컬 API smoke `saved_by_learner:local smoke saved worksheet`. 임상 루브릭, AI 추출/채점, 교수자 검수는 후속.
- C3 이론모드 1차: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, `build_turn_messages`는 인간중심/CBT/통합 프레이밍을 엔진 메시지에 넣는다. CBT 체인·이론부합 루브릭·명시적 선택 UI는 후속.
@ -33,7 +33,7 @@
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000`과 Tailnet 개발 API `127.0.0.1:8010``ENVIRONMENT=dev`로 떠 있다.
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-06-29 KST 재확인: `https://vignette.chanpaca.net/login?deploy=20260629` 200 with `assets/index-9KEDuuBQ.js` + `assets/index-DC83qEpU.css`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public unauth `GET /personas` 401.
- 2026-06-27 05:34:32 UTC 모바일 502는 cloudflared 로그상 `127.0.0.1:8001` origin connection refused와 일치한다. 현재 public API는 복구됐고, watchdog 기본 검사에서 아직 DNS가 없는 `api-vnet.18ka.net`을 제외해 향후 설치 시 불필요한 restart loop를 막았다. 2026-07-01 KST 재확인 대상: `https://vignette.chanpaca.net/login?deploy=20260701` 200 with `assets/index-B5dHolQ4.js` + `assets/index-PBfLFh-m.css`, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public unauth `GET /personas` 401.
- `GET /personas`는 이제 인증 필요다. 검증 당시 로컬 `http://127.0.0.1:8000/personas`와 현재 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료.
- 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다.
- 현재 확인용 프로세스는 `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `127.0.0.1:8000` dev API, `127.0.0.1:8010` Tailnet dev API, `127.0.0.1:5173` Vite dev web, `127.0.0.1:5174` public preview, `cloudflared` tunnel 1개다. Tailnet URL은 `https://alpaca-home.taile93291.ts.net/login`이다. stale Vite allowedHosts로 403이 났던 상태는 재검증 시 `login` 200, `/api/health` dev/db/engine true, `/api/auth/config` 200이었다. `alpaca-home.taile93291.ts.net`는 Vite 기본 `allowedHosts`에도 포함했다.
@ -170,7 +170,7 @@
- Public web deployment:
- `wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true`
- 2026-06-29 manual deployment, preview `https://eb2ed257.vignette-b1q.pages.dev`
- `https://vignette.chanpaca.net/` serves `assets/index-9KEDuuBQ.js` and `assets/index-DC83qEpU.css`
- `https://vignette.chanpaca.net/` serves `assets/index-B5dHolQ4.js` and `assets/index-PBfLFh-m.css`
- `https://vignette.chanpaca.net/live2d/mao/Mao.model3.json`, Haru model, and Cubism core routes return 404
- Public login screen:
- `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --grep "public login"`

File diff suppressed because one or more lines are too long

View file

@ -90,7 +90,7 @@ vignette/
`GET /health`는 liveness + DB readiness(`db.healthcheck()`) + 엔진 게이트웨이 readiness
(`engine_client.health_detail()`)를 합쳐 `{"status": "ok|degraded", db, engine, engine_mode, ...}`를 반환한다.
DB readiness는 auth/admin 테이블뿐 아니라 세션 read-model 핵심 테이블·컬럼
(`app.sessions`, `app.turns.provider_events`, `app.session_review_status` worksheet 컬럼)을 함께 확인한다.
(`app.sessions`, `app.turns` 음성 메타 컬럼, `app.session_review_status` worksheet 컬럼)을 함께 확인한다.
### 2.2 턴 오케스트레이터 — `app/services/orchestrator.py`
@ -375,10 +375,15 @@ session lifecycle을 유지하며, future Node read API는 이 read-model contra
- `GET /teacher/dashboard` — 담당 학습자 성장, 안전 알림, 종료 회기 검토 큐를 반환한다. 회기 요약에는
`review_status`, `review_note`, `reviewed_at`를 포함해 교수자가 이미 검토한 회기를 구분한다.
- `GET /teacher/learners/{learner_id}/analysis` — 담당 범위 안의 특정 학습자 전체 회기 타임라인을
오래된 순서로 반환한다. `summary`/`points`는 제한 없는 사용자별 추이를 담고, `stage_breakdown`
라포·탐색·개입·정리 단계별 회기 수와 턴 수를 담는다. 담당 범위 밖 learner id는 404로 닫는다.
- `PUT /teacher/sessions/{session_id}/review-status` — 교수자/관리자가 회기 검토 메모와 상태를 저장한다.
저장 대상은 `app.session_review_status`이며, 검토 완료된 회기는 pending queue에서 제외된다.
- `/teach/session/:sessionId/review` 화면은 같은 `GET /sessions/{id}/review` 자료를 교수자 읽기 전용으로 표시하고,
검토 메모 저장은 위 teacher endpoint로 분리한다.
- `/teach` 교수 콘솔은 검토 큐·위기 알림·페르소나 검수·최근 회기 triage만 맡는다.
`/teach/analysis` 학생 분석은 별도 메뉴/라우트로 분리되어 담당 학습자 목록, 선택 학습자의
전체 회기·단계 분포·회기별 추이를 보여준다. `/teach/session/:sessionId/review` 화면은 같은
`GET /sessions/{id}/review` 자료를 교수자 읽기 전용으로 표시하고, 검토 메모 저장은 위 teacher endpoint로 분리한다.
### 2.9.2 운영 메일 알림 — `app/services/notifications.py`
@ -541,6 +546,7 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner/admin(learner 관점) |
| `/learn/avatar-expressions` | AvatarExpressionLab | learner/admin(learner 관점) |
| `/teach` | Professor(교수자 대시보드) | teacher/admin |
| `/teach/analysis` | Professor(학생 분석 작업면) | teacher/admin |
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher/admin |
| `/admin` | Admin(운영 홈) | admin |

View file

@ -62,7 +62,7 @@
| **H1** | 계약 평가 KPI(자기효능감·기술숙련도·수련만족도 사전사후) 수집·입력·CSV/report 계산 2차 | `app.learner_prepost_measure`, 학습자 본인용 `GET/PUT /users/me/prepost-measures`, `SessionReview`의 파일럿 증거 원장 카드가 3척도 pre/post 1~5 aggregate evidence를 저장·조회한다. `app.services.phase3_kpi_export``scripts/export-phase3-kpi.py`는 원장 row를 Phase 3 evidence root의 `02-measures/prepost_measures.csv``02-measures/kpi_report.json` scaffold로 산출한다. participant id는 가명화하고, 3척도 paired normalized mean pre/post/delta, complete/missing pair를 계산한다. 2차에서는 `phase3_kpi_contract.py`가 KPI metric 이름·필수키·`computed_prepost`/`design_pending` status 값을 소유해 exporter/checker/test의 drift를 줄이고, 계산 가능한 pre/post evidence와 평가설계 전 미계산 KPI를 report 안에서 구분한다. | 공식 문항 확정, 실험/통제군 배정, 추이 시각화, 통계검정 종류/alpha/결측 처리, 실제 20명 evidence와 steward/legal/IAA 검수. 현재 API/UI/export는 공식 효과성·성적·수료 판정이 아니라 파일럿 evidence 계산이다. (κ/ICC·환각률은 doc4 미명시 → 평가설계 확정.) | doc4(20명 실험/통제군·단회기 50분·3척도 pre-post) |
| **H2** | 턴별 fast-loop + 라이브 코칭 1차 가동·학습자 리뷰 3열 workbench 완료·골든셋 잔여 | `make_eval_hook`이 submit/voice 생성 경로에 주입되고, stream은 `_evaluate_stream_turn`으로 fast-loop 평가를 붙인다. 결과는 `feedback_scores`, `alternative_utterance` 등 정규화 테이블에 적재·hydrate된다. 추가로 `app/services/live_coach.py`, `POST/GET /sessions/{id}/live-coach`, `POST /kb/live-coach/source-packs/sync`, `app.live_coach_events`, `data/kb/live_coaching_workbook_0615.json`, `data/kb/live_coaching_sources/*.json`을 연결해 워크북·DSM·공식 지침 요약 기반 코칭 아바타 말풍선·근거 모달·발화별 이력 오버레이와 RAG 증분 색인을 제공한다. `app.services.source_pack_sync`가 repo source pack의 active `content_hash`를 비교하고 변경 시 document version을 최신+1로 올린다. live turn은 프로세스 로컬 source pack snapshot을 재사용하지만, 관리자 sync/CLI는 `refresh=True`로 캐시를 비운 뒤 repo 파일을 다시 읽어 stale `content_hash` 비교를 막는다. 학습자 `SessionReview` 데스크톱은 요약/흐름, 축어록, 평가 rail의 3열 workbench와 하단 워크시트로 재배치했고, empty review는 2열 이하로 유지한다. 워크시트/pre-post/교수자 메모 입력과 발화 이동 버튼은 name/autocomplete/aria-label 및 공유 focus token으로 접근성 회귀 표면을 줄였다. | 원천 축어록 few-shot 골든셋 적재, 임상팀 확정 루브릭과 source pack 임상 검수 상태 운영정책 보강. | doc2·doc5(골드 포맷) |
| **H3** | 임상팀 콘텐츠 입력 경로(페르소나 저작 CRUD) 2차 구현·원문 격리 정책 1차·항목형 목록 저작/프롬프트 검토 UI 완료·임상 검수 잔여 | draft 생성·조회·편집·검수요청 API와 교수 콘솔 JSON 초안 패널은 연결됐다. `persona_repository.py`는 in-code `SEED_PERSONAS`(P1~P3)와 `data/personas/P4.json`~`P7.json``PersonaCard`로 합쳐 `materialize_seed_personas()`와 seed fallback catalog에 포함한다. `scripts/materialize-persona-seeds.py`는 같은 seed manifest를 dry-run 기본으로 보고하고, `--apply`에서만 DB pool을 초기화한 뒤 기존 idempotent DB materializer를 호출한다. `scripts/sync-persona-sources.py`는 DB-backed dry-run/apply runner로 repo-managed source pack의 `content_hash`/document version을 비교한다. RAG 기반 draft 생성은 source/chunk evidence와 함께 `persona-draft-rag@2026-06-28.1` prompt bundle id/version/hash를 engine metadata 및 draft `source_provenance`에 남긴다. `POST /personas/sources`는 raw 원문 hash-only 증거를 `kb.raw_source_artifact`에 따로 기록하고, sanitized 파생본만 evaluator-only RAG chunk로 색인한다. `rag.index_document()``sensitivity=3` 또는 raw marker chunk를 DB 접근 전에 차단한다. `app/persona_read_model.py`는 catalog/review/draft/source/evidence DTO와 mapper를 route에서 분리해 OpenAPI schema 이름을 유지한다. PersonaStudio는 자동사고, 회기 시나리오, 말투 filler/verbal tic/nonverbal cue, 역린·금기 응답·금기어를 행 추가/삭제 UI로 편집하고, 저장 직전 빈 항목을 제거하되 기존 배열 schema를 유지한다. 프롬프트 탭은 raw JSON textarea 대신 L1 카드·인적 범주·임상 배경·말투·수치 파라미터·역린·회기 시나리오·추가 계약 섹션으로 같은 draft 데이터를 검토하게 한다. | 루브릭·이론 콘텐츠 외부화, P4~P7 포함 임상팀 최종 검수/서면 evidence 확보, 암호화 blob/vault 기반 원문 실저장. | doc3(R&R)·doc4(페르소나=전문가 산출물) |
| **H4** | PII 마스킹 한국어 로컬 휴리스틱 + optional ko recognizer adapter + 15-case fixture/schema 평가 harness + 온보딩·동의 게이트 잔여 | Presidio `language='en'` 고정 한계를 보완하기 위해 정규식 폴백에 한국어 날짜·금액·행정구역 주소와 함께 이름/성명 라벨, 성씨+이름+조사/호칭, 대학교·학과·병원·센터 등 기관 suffix 기반 로컬 휴리스틱 마스킹을 추가했다. 66차에서는 `제 이름은 김서연입니다`, `보호자 이름은 박민수입니다`, `저는 최하늘입니다` 같은 자연 발화형 이름 라벨·자기소개 패턴을 추가하고 `이름은 중요하지 않다` negative control로 오탐을 막았다. Presidio가 설치돼도 한국어 누락을 막기 위해 fallback을 후단에 한 번 더 태운다. 70차에서는 `guardrail.mask_pii()` 내부에 선택형 한국어 PII recognizer adapter 경계를 추가했다. adapter는 import-time hard dependency가 아니며 명시 등록 전에는 비활성이고, 실패해도 기존 regex fallback이 마지막 안전망으로 유지된다. fake adapter 테스트는 regex가 못 잡는 별명/기관 span을 `[NAME]`/`[ORG]`로 마스킹하고 같은 문장의 전화번호는 후단 regex가 `[PHONE]`으로 처리하는지, adapter 실패 시에도 fallback이 유지되는지 검증한다. 상담 생성(generate/stream), fast evaluator prompt, client turn `text_masked`에서 한국어 NAME/ORG raw 값이 남지 않도록 회귀화했다. `app.services.pii_masking_eval`, `data/privacy/pii-masking-ko-fixtures.json`, `scripts/evaluate-pii-masking.py`로 합성 fixture 15케이스를 NAME/ORG/PHONE/EMAIL/RRN/NUMID/DATE/MONEY/ADDR/negative-control 범위에서 entity recall·forbidden substring removal·unexpected entity violation으로 평가한다. `data/privacy/pii-masking-eval-input.schema.json``data/privacy/pii-masking-eval-report.schema.json`은 source/category/severity metadata와 summary-only `technical_dry_run` 리포트 계약을 고정하며, 기본 CLI JSON은 `masked_text`/`forbidden_remaining` 원문 증거를 제외한다. `소속`/`안내`/`이름` NAME 오탐도 stopword로 보정했다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user`에 이름·소속·학과·학년/직위·연락처·주소/수령지·닉네임·자기소개·아바타 URL·약관/개인정보 동의 버전 필드를 추가했고, 로그인 직후 `/onboarding` 완료 전에는 역할 홈과 learner 회기 시작을 막는다. 아바타 이미지는 `/users/me/avatar`에서 MIME/시그니처/3MB 제한 후 파일 저장소에 두고 URL만 보관한다. 온보딩 저장 시 learner `consent_at`도 함께 세팅하며 auth E2E에서 신규 계정 온보딩→아바타 업로드→학습자 홈 이동을 검증했다. | 실제 ko recognizer 모델/provider 선정, 운영 말뭉치 기반 오탐/미탐 평가, 미성년/guardian 및 법무 검토가 필요한 최종 서명 동의서·개인정보 처리방침·약관 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) |
| **H4** | PII 마스킹 한국어 로컬 휴리스틱 + optional ko recognizer adapter + 16-case fixture/schema 평가 harness + 온보딩·동의 게이트 잔여 | Presidio `language='en'` 고정 한계를 보완하기 위해 정규식 폴백에 한국어 날짜·금액·행정구역 주소와 함께 이름/성명 라벨, 성씨+이름+조사/호칭, 대학교·학과·병원·센터 등 기관 suffix 기반 로컬 휴리스틱 마스킹을 추가했다. 66차에서는 `제 이름은 김서연입니다`, `보호자 이름은 박민수입니다`, `저는 최하늘입니다` 같은 자연 발화형 이름 라벨·자기소개 패턴을 추가하고 `이름은 중요하지 않다` negative control로 오탐을 막았다. Presidio가 설치돼도 한국어 누락을 막기 위해 fallback을 후단에 한 번 더 태운다. 70차에서는 `guardrail.mask_pii()` 내부에 선택형 한국어 PII recognizer adapter 경계를 추가했다. adapter는 import-time hard dependency가 아니며 명시 등록 전에는 비활성이고, 실패해도 기존 regex fallback이 마지막 안전망으로 유지된다. fake adapter 테스트는 regex가 못 잡는 별명/기관 span을 `[NAME]`/`[ORG]`로 마스킹하고 같은 문장의 전화번호는 후단 regex가 `[PHONE]`으로 처리하는지, adapter 실패 시에도 fallback이 유지되는지 검증한다. 상담 생성(generate/stream), fast evaluator prompt, client turn `text_masked`에서 한국어 NAME/ORG raw 값이 남지 않도록 회귀화했다. `app.services.pii_masking_eval`, `data/privacy/pii-masking-ko-fixtures.json`, `scripts/evaluate-pii-masking.py`로 합성 fixture 16케이스를 NAME/ORG/PHONE/EMAIL/RRN/NUMID/DATE/MONEY/ADDR/negative-control 범위에서 entity recall·forbidden substring removal·unexpected entity violation으로 평가한다. `data/privacy/pii-masking-eval-input.schema.json``data/privacy/pii-masking-eval-report.schema.json`은 source/category/severity metadata와 summary-only `technical_dry_run` 리포트 계약을 고정하며, 기본 CLI JSON은 `masked_text`/`forbidden_remaining` 원문 증거를 제외한다. `소속`/`안내`/`이름` `진로는` 같은 NAME 오탐도 보정했다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user`에 이름·소속·학과·학년/직위·연락처·주소/수령지·닉네임·자기소개·아바타 URL·약관/개인정보 동의 버전 필드를 추가했고, 로그인 직후 `/onboarding` 완료 전에는 역할 홈과 learner 회기 시작을 막는다. 아바타 이미지는 `/users/me/avatar`에서 MIME/시그니처/3MB 제한 후 파일 저장소에 두고 URL만 보관한다. 온보딩 저장 시 learner `consent_at`도 함께 세팅하며 auth E2E에서 신규 계정 온보딩→아바타 업로드→학습자 홈 이동을 검증했다. | 실제 ko recognizer 모델/provider 선정, 운영 말뭉치 기반 오탐/미탐 평가, 미성년/guardian 및 법무 검토가 필요한 최종 서명 동의서·개인정보 처리방침·약관 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) |
### Medium+ (6) — M1~M3, X1~X2, L1
@ -91,7 +91,7 @@
- **H2 1차+라이브 코칭+리뷰 3열 workbench 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 라이브 코칭은 0615 워크북·DSM·공식 지침 요약/RAG 근거로 코칭 아바타 말풍선·근거 모달·발화별 이력 오버레이까지 연결했다. `POST /kb/live-coach/source-packs/sync`는 공용 source pack sync service를 통해 evaluator 전용 RAG에 증분 색인하고, hash 변경 시 document version을 최신+1로 올린다. 관리자 sync/CLI는 source pack manifest 생성 전 process-local cache를 refresh해 현재 repo JSON 기준으로 비교한다. 학습자 `SessionReview` 데스크톱은 요약/흐름, 축어록, 평가 rail의 3열 workbench와 하단 워크시트로 재배치했고, 교수자/모바일 레이아웃은 기존 규칙을 유지한다. 후속은 골든셋·임상팀 루브릭·source pack 임상 검수 상태 운영.
- **H3 10차 완료**: 페르소나 저작 CRUD(draft→review), P4~P7 저장소 JSON 로드, RAG source 기반 draft generation, prompt bundle id/version/hash provenance와 seed/version materializer runner가 붙었다. seed runner는 dry-run/JSON manifest를 제공하고, `--apply`에서만 DB pool을 초기화해 기존 materializer를 호출한다. repo-managed source pack runner는 DB-backed dry-run/apply를 제공하며 active `content_hash`가 바뀐 문서만 최신 version+1로 sync한다. `kb.raw_source_artifact` hash-only 레코드와 `rag.index_document()` raw/sensitivity=3 fail-closed guard를 추가해 raw 원문이 `kb.chunk`/embedding/FTS로 들어가지 않게 했고, `app/persona_read_model.py`로 persona DTO/mapper 경계를 분리했다. PersonaStudio의 자동사고·회기 시나리오·말투 목록·역린/금기 목록은 행 추가/삭제 UI로 바꾸고 기존 배열 payload 계약을 유지한다. 이번 패스에서 프롬프트 미리보기 raw JSON textarea를 라벨형 검토 섹션으로 교체했다. 후속은 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수 evidence, 암호화 blob/vault 기반 원문 실저장.
- **H3 11차 / L1 계약 보강 완료**: `app/persona_generation_contract.py`가 페르소나 draft structured schema, prompt bundle id/version/hash, `GenerateResponse` payload extraction, generated draft defaults/coercion을 소유한다. `routes/personas.py`는 auth, RAG evidence, engine invocation, provenance assembly, HTTP error mapping을 유지한다. 이어서 evaluator/live-coach의 로컬 structured payload alias를 제거하고 `structured_payload_from_response()`를 직접 호출하게 했으며, `SessionEvaluationWrite``app.session_evaluation` 저장 packet을 소유한다. 최신 L1 검증은 persona contract+review 39 passed, gateway contract 27 passed, backend focused 120 passed, Node conformance OK, `npm run check:api-types`, `npm run typecheck`.
- **H4(부분)·X1·X2**: 한국어 날짜/금액/주소 및 이름/기관 로컬 휴리스틱 마스킹, 15-case 합성 fixture 평가 harness와 input/report schema(summary-only `technical_dry_run` report), 자연 발화형 이름 라벨·자기소개 마스킹 보강, optional ko recognizer adapter 인터페이스와 fake/failure 회귀, 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료했다. 실제 ko recognizer 모델/provider 선정, 운영 말뭉치 기반 평가, guardian/legal 서명 evidence는 후속이다. dry-run JSONL export·PII scan·IAA 계산 1차도 완료했고 `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고, evaluator fast/deep 모델 override, evaluator semantic cache, 운영 hit-rate 관측, 일별 비용 추이, 모델별 비용 검증 리포트는 완료했고 자동 차단·한도 enforcement 정책은 후속. M1은 provider_events 보존 슬롯, 내부 taxonomy, 인증 리뷰용 제한 파생 칩까지 완료했고, M2는 보수적 identity/agreement pinned_fact 자동 실적재, append-only history, 명시적 상담 약속 철회 contradiction, episodic embedding writer, 다음 턴 EngineMessage 주입 회귀, digest contract/local quality harness, one-shot worker/runner/default-off scheduler/CAS 경계까지 완료했다. L1은 Node.js conformance runner, `gateway-default` default-routing sentinel 정규화, session/persona read-model 분리까지 완료했다. 실제 provider 기반 한숨·울음 감지는 후속.
- **H4(부분)·X1·X2**: 한국어 날짜/금액/주소 및 이름/기관 로컬 휴리스틱 마스킹, 16-case 합성 fixture 평가 harness와 input/report schema(summary-only `technical_dry_run` report), 자연 발화형 이름 라벨·자기소개 마스킹 보강, optional ko recognizer adapter 인터페이스와 fake/failure 회귀, 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료했다. 실제 ko recognizer 모델/provider 선정, 운영 말뭉치 기반 평가, guardian/legal 서명 evidence는 후속이다. dry-run JSONL export·PII scan·IAA 계산 1차도 완료했고 `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고, evaluator fast/deep 모델 override, evaluator semantic cache, 운영 hit-rate 관측, 일별 비용 추이, 모델별 비용 검증 리포트는 완료했고 자동 차단·한도 enforcement 정책은 후속. M1은 provider_events 보존 슬롯, 내부 taxonomy, 인증 리뷰용 제한 파생 칩까지 완료했고, M2는 보수적 identity/agreement pinned_fact 자동 실적재, append-only history, 명시적 상담 약속 철회 contradiction, episodic embedding writer, 다음 턴 EngineMessage 주입 회귀, digest contract/local quality harness, one-shot worker/runner/default-off scheduler/CAS 경계까지 완료했다. L1은 Node.js conformance runner, `gateway-default` default-routing sentinel 정규화, session/persona read-model 분리까지 완료했다. 실제 provider 기반 한숨·울음 감지는 후속.
### B. 소유자 결정 / 외부(임상팀·기관) 의존

View file

@ -15,12 +15,12 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
| 검증 | 작업 디렉터리 | 명령 | DB | API(8000) | 웹(5173) | 엔진GW(9099) | 브라우저 | 현재 통과 |
|---|---|---|---|---|---|---|---|---|
| 백엔드 단위 테스트 | `apps/api` | `python -m pytest app/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 기준선 178 pass |
| 백엔드 단위 테스트 | `apps/api` | `python -m pytest app/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 현재 309 pass |
| 엔진 게이트웨이 테스트 | `apps/api` | `python -m pytest engine_gateway/ -q` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | 현재 27 pass |
| API 타입 생성 체크 | `apps/web` | `npm run check:api-types` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 타입체크 | `apps/web` | `npm run typecheck` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| 웹 빌드 | `apps/web` | `npm run build` | 불필요 | 불필요 | 불필요 | 불필요 | 불필요 | pass |
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 113 tests / 17 files |
| Playwright E2E(전체) | `apps/web` | `npm run e2e` | **필요(+시드)** | **필요** | 자동기동 | 일부만 | **필요** | 162 tests / 18 files |
핵심 원칙: **단위 테스트(pytest)와 타입체크/빌드는 외부 서비스 없이 단독 실행된다.**
**E2E만 풀스택(DB+API+웹+브라우저)을 요구한다.** 아래 각 절에서 근거와 절차를 설명한다.
@ -42,14 +42,14 @@ Vignette 저장소의 모든 검증 수단(백엔드 단위 테스트, 웹 타
```sh
# apps/api
python -m pytest app/ -q # 앱 단위 테스트 기준선 178 pass
python -m pytest app/ -q # 앱 단위 테스트 현재 309 pass
python -m pytest engine_gateway/ -q # 게이트웨이 단위 테스트 현재 27 pass
```
수집만 빠르게 확인하려면:
```sh
python -m pytest app/ --collect-only -q # 기준선: "178 tests collected"
python -m pytest app/ --collect-only -q # 현재: "305 tests collected"
python -m pytest engine_gateway/ --collect-only -q # 현재: "27 tests collected"
```
@ -258,23 +258,26 @@ VITE_API_BASE=http://127.0.0.1:8000 npm run e2e # 프록시 대신 API
### 3.6 실측 테스트 개수 (현재)
`npx playwright test --list` 기준 **총 113 tests / 17 files**.
`npx playwright test --list` 기준 **총 162 tests / 18 files**.
- **병렬 시나리오**: 96 tests (desktop + mobile)
- **`@single-run` 직렬 시나리오**: 17 tests (DB 영속화·세션 MVP·음성 성공경로 등)
- **병렬 시나리오**: 130 tests (desktop 65 + mobile 65)
- **`@single-run` 직렬 시나리오**: 32 tests (DB 영속화·세션 MVP·음성 성공경로·회기말 평가 저장·교수자 사용자별 분석 등)
- `e2e/voice-success.spec.ts`는 직접 `/voice/ws` 캐스케이드와 Session 마이크 UI를 함께 검증하며,
브라우저 `<audio>.play()`가 차단된 조건에서도 Web Audio buffer source 재생이 시작되는지 확인한다.
- 2026-06-27/28 기준선: `PLAYWRIGHT_PORT=5174 npm run e2e` **113 passed**.
브라우저 `<audio>.play()`가 차단된 조건에서도 Web Audio buffer source 재생이 시작되는지와
Session 마이크 UI가 `audio_end`에 browser voice activity/silence 메타를 싣는지 확인한다.
- 2026-07-01 focused 검증: `PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` **7 passed**. 실제 브라우저 `openSessionStream()``/sessions/{id}/stream` → DB-backed `/review` 축어록 저장 경로, AI 튜터 코칭 이력 저장/재로딩, WebSocket `stt_result` 음성 비언어 메타데이터, Session 마이크 UI가 생성한 voice activity/silence 메타, Phase 3 pre/post 점수의 DB-backed 저장/재조회, 그리고 세션 종료 background deep 평가가 durable DB row로 저장되어 교수자 리뷰가 `평가 완료`로 전환되는지 검증한다.
- 2026-07-01 focused 검증: `PLAYWRIGHT_PORT=5193 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1` **2 passed**. MVP 종료/리뷰 흐름과 위기 안전 게이트가 빈 내담자 응답 신호에 덮이지 않는지 검증한다.
- 2026-07-01 focused 검증: `PLAYWRIGHT_PORT=5211 npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1` **9 passed**. 교수자 콘솔의 페르소나 검수, 검토 큐, 최근 회기 진입과 별도 `/teach/analysis` 학생 분석 메뉴, 전체 회기 타임라인 전환을 검증한다.
레이아웃·시각 회귀 게이트(핵심 합격선):
| 게이트 | 스펙 | 구성 | 개수 |
|---|---|---|---|
| 세션 레이아웃 | `e2e/session-layout.spec.ts` | 4 테스트 × (desktop+mobile) | **8 / 8** |
| 시각 레이아웃 게이트 | `e2e/layout-visual-gate.spec.ts` | `@single-run`, 9개 화면 × 7개 폭 검사 + 다크 테마 assertion | **9 / 9** |
| 시각 레이아웃 게이트 | `e2e/layout-visual-gate.spec.ts` | `@single-run`, 11개 화면 × 7개 폭 검사 + 다크 테마 assertion | **11 / 11** |
| 레이아웃 포커스(재설계 화면) | `session-layout`·`session-review`·`admin`·`learner`·`settings`·`teacher`, `@single-run` 제외 | desktop+mobile 병렬 | **54** |
> `layout-visual-gate`는 7개 폭(390/720/861/900/1024/1280/1440)에서 9개 핵심 화면의 가로
> `layout-visual-gate`는 7개 폭(390/720/861/900/1024/1280/1440)에서 11개 핵심 화면의 가로
> 오버플로·잘린 컨트롤·다크 테마 적용을 검사하고 전체 페이지 스크린샷을
> `node_modules/.tmp/layout-gate/`에 남긴다.
> `session-layout`은 회기 전/활성 화면이 뷰포트를 벗어나지 않는지, 우측 패널이 코어 영역을

View file

@ -8,6 +8,26 @@
최신 동기화 추가(2026-06-29): 관리자·교수자 메일링 시스템 1차가 추가됐다. 가입 승인 요청과 회기 검토 요청은 `app.notification_event`/`app.notification_delivery`에 idempotent 큐로 남기고, SMTP 설정이 있을 때만 실제 메일을 발송한다. 메일 본문은 Vignette 톤앤매너를 따르되 축어록·평가 전문을 포함하지 않고 `/admin/users`, `/teach/session/:sessionId/review` 딥링크만 제공한다.
최신 동기화 추가(2026-06-30): 기본 역할이 learner로 복원되는 슈퍼관리자 계정이 타 학습자 회기 리뷰 딥링크에서 `session does not belong to user` 403에 막히던 문제를 수정했다. 리뷰 조회는 본인 학습자 접근을 우선 유지하고, 실패 시 슈퍼관리자만 admin 감독자 읽기로 재시도한다. 프론트는 동일 403뿐 아니라 이미 `teacherReview`가 포함된 성공 응답도 감독자 권한 사용자에 한해 `/teach/session/:sessionId/review`로 정규화한다. 일반 learner 및 단순 `admin_access` 위임 계정의 타인 세션 접근 차단은 유지했다. 검증: `app/test_rbac_idor.py` 11 passed, RBAC/auth/teacher focused 47 passed, supervisor route E2E 2 passed(`PLAYWRIGHT_PORT=5176`), `npm run typecheck`, `npm run build`.
최신 동기화 추가(2026-06-30): `docs/dev_dashboard.html` 자체를 레드팀 검증 대상으로 추가했다. `scripts/test_dev_dashboard_ssot.py`는 SSOT checker import/stateless/owner parser를 unit으로 검증하고, `apps/web/e2e/dev-dashboard.spec.ts`는 local file, `127.0.0.1`, `localhost` origin에서 필터 오순서·빠른 재시도·키보드·details/tabs·hash anchor·링크/이미지 무결성·desktop/mobile overflow를 E2E로 확인한다. 같은 라운드의 라이브 점검에서 public API `https://api-vignette.chanpaca.net/health``/personas`가 Cloudflare 530/error code 1033으로 실패하는 것을 발견했다. 원인은 Docker Desktop 중단으로 `vignette-dev-db`가 꺼진 상태였고, Docker Desktop/DB 재기동 뒤 `scripts/start-public-runtime.ps1`를 다시 실행해 public health 200, unauth `/personas` 401, auth config 200, Google login start 302, watchdog healthy: engine, api, web-preview, cloudflared, public-api까지 복구했다. 검증: dev-dashboard E2E redteam 10 passed, SSOT unit 3 OK, dashboard checker PASS.
최신 동기화 추가(2026-07-01): 하린/P6 종료 회기의 `SUPERVISOR AI 평가 실패`는 평가가 실행되지 않은 문제가 아니라 102분/138턴 축어록에 대한 회기말 평가 background task가 기존 45초 하드캡에서 timeout된 뒤 `TimeoutError`의 빈 문자열이 그대로 저장된 문제였다. 회기말 평가는 `SESSION_EVALUATION_TIMEOUT` 또는 `ENGINE_TIMEOUT`을 사용하고, 빈 예외 문자열은 타입/명시 메시지로 저장한다. 교수자 재평가 API는 실패 결과를 저장한 뒤 502/503으로 노출하고, 프론트는 종료 실패 alert, 평가 대기 polling, 교수자 `AI 평가 재시도` 버튼을 제공한다. 재시도 실패가 UI에서 조용히 사라지지 않고 기존 실패 리뷰와 새 실패 사유를 유지하는지, 저장된 평가 실패 row가 read-model에서 `평가 실패`와 사유로 노출되는지도 회귀 테스트로 고정했다. 모바일 코칭 모드에서 우측 컬럼 숨김 때문에 AI 튜터 카드가 보이지 않던 회귀도 수정했다. 검증: `python -X utf8 -m pytest app/ -q` 297 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `session-review` desktop/mobile 16 passed, `session-mvp` desktop/mobile/single-run 5 passed, `session-layout` desktop/mobile 8 passed, `layout-visual-gate` 9 passed.
최신 동기화 추가(2026-07-01): 실제 브라우저 SSE stream 저장 경로를 E2E로 추가 검증했다. 기존 `session-persistence.spec.ts`는 real API/DB를 쓰지만 `/api/sessions/{id}/turn` 직접 호출이라 UI의 `openSessionStream()``/sessions/{id}/stream` → 저장 finalize 경로를 타지 않았다. 새 `@single-run` 테스트는 `db=true`, `engine=true` health 조건에서 Session UI로 회기 시작, 학습자 발화 전송, stream 완료 대기, DB-backed `/review` learner/client 축어록 확인까지 수행한다. 이어서 AI 튜터 코칭 카드가 생성만 되고 `app.live_coach_events` 저장 실패가 묻히는 silent failure를 막기 위해 `POST /sessions/{id}/live-coach`의 저장 실패를 503으로 표면화하고, 실제 UI 코칭 모드 → DB-backed `/live-coach` 이력 → 재로딩 후 `C` 마커 유지까지 E2E로 고정했다. crisis `safety`/`done.conversation_stopped` 또는 음성 `reply.conversation_stopped`도 빈 응답으로 일반화하지 않고 109 안전 게이트를 유지하도록 수정했다. 검증: `python -X utf8 -m pytest -p no:cacheprovider app/test_session_turn_persistence.py -q` 27 passed, `PLAYWRIGHT_PORT=5193 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1` 2 passed, 최신 `PLAYWRIGHT_PORT=5202 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 6 passed, `npm run typecheck`, `npx playwright test --list` 현재 160 tests / 18 files.
최신 동기화 추가(2026-07-01): 음성 비언어 메타데이터도 mock/fixture 전용 검증에 머물던 구간을 좁혔다. `db.healthcheck()`, runtime table readiness, deploy preflight DB mode가 `app.turns.provider_events` 하나가 아니라 `audio_ref`/`silence_ms`/`speech_rate`/`barge_in`/`provider_events` 5개 컬럼 전체를 확인한다. dev/runtime bootstrap은 누락된 nullable 음성 컬럼도 보강한다. `session-persistence.spec.ts`에는 실제 DB 회기를 만들고 `/api/voice/ws` `stt_result` control 및 실제 Session 마이크 UI 생성 메타를 DB-backed `/review` 제한 `nonverbal` 칩까지 검증하는 `@single-run` E2E를 추가했다. 검증: `python -X utf8 -m pytest -p no:cacheprovider app/test_runtime_policy.py app/test_session_turn_persistence.py app/test_voice_ws.py app/test_voice_service.py -q` 77 passed, `PLAYWRIGHT_PORT=5199 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 5 passed, `python -X utf8 -m py_compile app/db.py app/auth_sessions.py app/test_runtime_policy.py ..\..\scripts\check-deploy-preflight.py`, `python -X utf8 scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets`.
최신 동기화 추가(2026-07-01): 실제 Session 마이크 UI 경로도 음성 비언어 메타를 생성하도록 보강했다. `AudioWorklet` 캡처는 PCM RMS/peak 기반으로 voice activity와 trailing silence를 추정하고 `audio_end``silence_ms`, `barge_in=false`, `provider_events`(`voice_activity`, `silence`)를 싣는다. `MediaRecorder` fallback은 raw PCM을 볼 수 없으므로 녹음 duration 기반 voice activity만 보낸다. 리뷰 read-model은 turn-level `silence_ms`가 이미 침묵 칩을 만들면 provider `silence` 칩을 중복 노출하지 않는다. `voice-success.spec.ts`는 실제 마이크 버튼 경로가 `audio_end` 메타를 보내는지 확인하고, `session-persistence.spec.ts`는 그 UI 생성 메타가 DB-backed `/review` `nonverbal`까지 이어지는지 확인한다. 검증: `npm run typecheck`, `python -X utf8 -m pytest -p no:cacheprovider app/test_runtime_policy.py app/test_session_turn_persistence.py app/test_voice_ws.py app/test_voice_service.py -q` 77 passed, `PLAYWRIGHT_PORT=5197 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1` 2 passed, 최신 `PLAYWRIGHT_PORT=5202 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 6 passed, `npx playwright test --list` 현재 160 tests / 18 files.
최신 동기화 추가(2026-07-01): 회기 종료 후 background deep 평가가 실제 durable DB row로 저장되는지 fixture 없이 검증했다. `_schedule_session_evaluation()`은 생성 task를 반환하고 done callback에서 crash/cancel을 관찰하며, 평가 결과·timeout/error 저장이 durable store에 닿지 않으면 명시 로그를 남긴다. 교수자 명시 재평가 API는 평가 산출물이 생성됐지만 저장 실패하면 503으로 노출하고, `GET /eval/sessions/{id}/evaluation``status/error/durable`을 제공해 캐시 복원과 DB 저장 증거를 구분한다. `session-persistence.spec.ts`는 학습자 세션 생성→턴→종료→teacher 로그인→`/eval/.../evaluation` polling→교수자 리뷰 UI `평가 완료`까지 실제 DB/API/엔진으로 확인한다. 검증: `python -X utf8 -m pytest -p no:cacheprovider app/ -q` 309 passed, `npm run check:api-types`, `npm run typecheck`, `PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 7 passed, `PLAYWRIGHT_PORT=5203 npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1` 16 passed, `npx playwright test --list` 현재 162 tests / 18 files.
최신 동기화 추가(2026-07-01): 교수자 회기 리뷰 데스크톱에서 `overview`/`transcript`가 여러 CSS Grid row를 가로질러 오른쪽 검토 rail 높이를 나눠 먹으며 중앙에 큰 빈 row가 생기던 문제를 제거했다. 1181px 이상 교수자 리뷰는 main column + review rail 2열 wrapper로 분리하고, main 내부는 요약 전체폭, 차트/회기 흐름 2열, 축어록 전체폭으로 배치한다. 회귀 방지로 `layout-visual-gate.spec.ts`에 교수자 리뷰 폭별 dead vertical gap 검증을 추가했다. 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1` 8 passed, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 10 passed, `npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1` 8 passed.
최신 동기화 추가(2026-07-01): 교수자 학생 분석을 콘솔에서 분리했다. `GET /teacher/learners/{learner_id}/analysis`는 담당 범위 안의 특정 학습자 전체 회기를 오래된 순서로 반환하고, 제한 없는 회기별 추이 point, 라포·탐색·개입·정리 단계 분포, 검토 대기/완료 카운트를 함께 내려준다. `/teach`는 검토 큐·위기 알림·페르소나 검수·최근 회기 triage만 맡고, 좌측 메뉴의 `/teach/analysis`가 학습자 목록 레일 → 사용자별 분석 → 전체 회기 타임라인 → 회기 리뷰 드릴다운을 맡는다. 검증: `python -X utf8 -m pytest -p no:cacheprovider app/test_teacher_dashboard.py -q` 5 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `PLAYWRIGHT_PORT=5211 npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1` 9 passed, `PLAYWRIGHT_PORT=5214 npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 11 passed, `python -X utf8 scripts/check-dev-dashboard-ssot.py --json` PASS, `npx playwright test --list` 현재 163 tests / 18 files.
최신 동기화 추가(2026-07-01): 원천문서 갭 E2E 탐색을 이어서 H1/C3 silent failure 후보를 좁혔다. `session-persistence.spec.ts`는 Phase 3 pre/post 3척도 입력이 브라우저 리뷰 UI → `PUT /users/me/prepost-measures` → DB-backed `GET /users/me/prepost-measures` → 새로고침 후 UI 복원까지 실제 API/DB로 이어지는지 검증한다. C3 수동 재평가는 persona 기본 `theory_target`이 학습자가 선택한 session `theory_mode`를 덮어쓰던 우선순위 오류를 수정했고, turn 재평가도 `TurnContext.theory_mode`를 채우도록 회귀화했다. 서브에이전트 병렬 조사 결과 남은 큰 공백은 C1 워크시트 learner-save/teacher-review full browser+API+DB E2E, C2 safety_events→교수자 대시보드 E2E, H2 live-coach evidence modal/source-pack actual sync proof다. 검증: `python -X utf8 -m pytest -p no:cacheprovider app/test_eval_routes.py app/test_notifications.py app/test_session_turn_persistence.py -q` 39 passed, `PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 7 passed, `npx playwright test --list` 현재 162 tests / 18 files.
분류: **B1 비차단 폴리시** · **B2 환경 제약(증거 생산 불가)** · **B3 소유자 결정** · **B4 외부 거버넌스**
> **B0. 원천문서 갭 분석 (2026-06-26 추가)** — 한신대 산학협력 원천문서 5종 정독으로 도출한 "부족한 부분"(critical 3 / high 4 / medium+ 6)은 **SSOT 대시보드** `docs/dev_dashboard.html` "원천문서 갭 분석" 섹션과 상세 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1 사례개념화 산출물은 저장형 워크시트, 외부 루브릭 scaffold, 교수자 수동 검수 상태 저장까지 4차 구조를 만들었고, C2 위기개입 프로토콜은 1차 구조, C3 이론모드는 2차 명시 선택 UI까지 만들었다. 콘텐츠 정의는 임상팀(구훈정·어유경) 소유라 코드는 구조를 선제 구축하되 임상 문안과 평가기준은 외부 정의로 받는다.
@ -27,6 +47,11 @@
- [x] **OpenAI TTS voice preset DB map 연결** — (2026-06-28 처리) `app.persona_voice_map`의 OpenAI row를 `/voice/ws` TTS voice 선택에 연결했다. 명시 query preset은 DB map보다 우선하고, 세션 바인딩은 `app.sessions.persona_id/persona_version`으로 voice map을 찾으며, dev persona 생성은 catalog persona id/version map을 사용한다. seed materializer는 기본 OpenAI voice map을 `ON CONFLICT DO NOTHING`으로 생성하고, dev 런타임 스키마 보강은 기존 DB의 `persona_voice_map` 누락도 복구한다. OpenAI가 아닌 provider row는 기존 persona-code fallback으로 안전하게 흡수한다. **검증: `pytest app/test_runtime_policy.py app/test_persona_review.py app/test_voice_service.py app/test_voice_ws.py -q` 75 passed, `pytest app/ -q` 178 passed, `pytest engine_gateway/ -q` 11 passed.**
- [x] **빈상태 컬럼 높이 여백** — (2026-06-28 처리) session-review 0건 상태는 1280px 이상에서 3컬럼 masonry를 쓰지 않고 빈 리뷰 전용 2컬럼 순차 레이아웃으로 전환한다. 가짜 기록·장식 콘텐츠를 넣지 않고, `EMPTY_REVIEW_SESSION_ID` fixture와 `session-review-empty` visual gate로 390/720/861/900/1024/1280/1440 전 폭을 검증한다. session-prestart와 learner-home은 기존 gate 대상에 남겨 함께 무회귀 확인했다. **검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 9 passed, `npx playwright test e2e/session-layout.spec.ts e2e/session-review.spec.ts --project=chromium-desktop --workers=1` 6 passed.**
- [x] **다크 UI v2 시안 실제 적용 라운드** — (2026-06-29 처리) `02/03/04-*-v2-dark-unified.png` 시안을 실제 React/CSS 화면으로 반영했다. P1 라이브 상담 세션은 내담자 무대 발화, 좌측 세션 진행, 우측 라이브 신호/안전 점검을 다크 훈련 화면으로 재구성했다. P2 회기 리뷰는 learner filled 상태를 3열 workbench(요약/축어록/평가 rail + 하단 워크시트)로 바꾸고 empty review는 sparse third column 없이 유지했다. P3 학습자 홈은 대시보드에서 연습 대상 레일을 제거하고, 진행 회기/리뷰 대기/최근 평가/라포 흐름을 상단 CTA 바로 올렸으며, 오늘 이어갈 회기·AI 코치·다음 연습 추천·최근 피드백을 하나의 `lh-work-cluster` 작업 묶음으로 재배치했다. 오른쪽 레일은 최근 기록, 리뷰 대기, 반복 대상만 담당한다. 생성 PNG는 앱에 직접 넣지 않고 레이아웃·밀도·톤 기준으로만 사용했다. 새 실제 렌더 캡처: `docs/design-verification/full-pages/02-learner-home-{desktop,tablet,mobile}.png`, `03-session-active-{desktop,tablet,mobile}.png`, `04-session-review-{desktop,tablet,mobile}.png`, `04-session-review-empty-{desktop,tablet,mobile}.png`. 실행 기록은 `docs/ops/dark-ui-refresh-implementation-plan-2026-06-28.md`에 고정했다. **최신 검증: `npm run typecheck`, `npm run build`, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 9 passed.**
- [x] **세션 종료 평가 실패 복구 UX + 원인 저장** — (2026-07-01 처리) 긴 회기 종료 후 AI 평가가 45초 timeout으로 실패하면서 `error=""`만 저장되던 silent failure를 제거했다. 종료 실패는 리뷰 화면으로 넘어가지 않고 alert로 표시하며, 종료 후 리뷰는 `reviewReady=false` 상태에서 대기 polling으로 ready 전환을 기다린다. 교수자 검토 화면은 평가 실패/미준비 상태에서 `AI 평가 재시도` 버튼으로 `/eval/sessions/{id}/reevaluate`를 호출한다. 재시도 API는 실패 결과도 DB에 저장한 뒤 non-2xx로 노출한다. 재시도 실패 UI 유지와 저장된 평가 실패 read-model 노출도 회귀 테스트로 고정했다. background task crash 관찰과 저장 실패 표면화, `status/error/durable` 평가 조회, 실제 종료 후 durable DB 평가 row 생성 E2E까지 추가했다. 모바일 코칭 모드의 AI 튜터 카드 숨김 회귀도 함께 수정했다. **검증: `python -X utf8 -m pytest -p no:cacheprovider app/ -q` 309 passed, `npm run check:api-types`, `npm run typecheck`, `PLAYWRIGHT_PORT=5205 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 7 passed, `PLAYWRIGHT_PORT=5203 npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --project=chromium-mobile --workers=1` 16 passed, 기존 `session-mvp`/layout focused gates 유지.**
- [x] **브라우저 SSE stream 저장 경로 회귀** — (2026-07-01 처리) `session-mvp` mock과 직접 `/turn` API 테스트 사이에 있던 실제 UI stream 저장 공백을 줄였다. `session-persistence.spec.ts`에 브라우저 Session UI가 실제 `POST /sessions/{id}/stream`을 호출하고, stream 완료 뒤 DB-backed `/review`에서 학습자/내담자 축어록이 반환되는지 검증하는 `@single-run` 테스트를 추가했다. 같은 파일에 AI 튜터 코칭 이력 저장/재로딩 회귀와 음성 비언어 메타 리뷰 회귀도 추가했다. **검증: `PLAYWRIGHT_PORT=5199 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 5 passed, `npm run typecheck`.**
- [x] **AI 튜터 코칭 이력 저장 실패 표면화** — (2026-07-01 처리) `POST /sessions/{id}/live-coach`가 코칭 제안 생성 후 `save_live_coach_event()` 실패를 `except/pass`로 묻어 카드만 보이고 이력/재로딩에는 사라질 수 있던 silent failure를 제거했다. 저장 계층의 prod fail-closed 503을 그대로 사용자/프론트에 노출하고, DB-backed `/live-coach` 이력과 학습자 발화 `C` 마커가 재로딩 뒤 유지되는지 E2E로 고정했다. **검증: `python -X utf8 -m pytest -p no:cacheprovider app/test_session_turn_persistence.py -k live_coach -q` 4 passed, `PLAYWRIGHT_PORT=5199 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 5 passed.**
- [x] **음성 비언어 메타 DB readiness + 리뷰 E2E** — (2026-07-01 처리) `/health`, runtime table readiness, deploy preflight가 `app.turns` 음성 메타 5개 컬럼 전체를 확인하게 했다. `session-persistence.spec.ts`는 DB-backed voice WS `stt_result`와 실제 Session 마이크 UI 생성 메타 → `/review` `nonverbal` 파생 칩까지 고정한다. 실제 provider 기반 한숨·울음 감지와 물리 마이크 장시간 WSS 실측은 계속 후속 gate다. **검증: backend focused 77 passed, `PLAYWRIGHT_PORT=5197 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1` 2 passed, `PLAYWRIGHT_PORT=5199 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 5 passed, preflight skip-db PASS.**
- [x] **위기 안전 게이트 UI 덮어쓰기 방지** — (2026-07-01 처리) SSE/음성 위기 종료가 빈 client reply로 끝날 때 `내담자 응답 없음` 신호와 live-coach 호출이 안전 게이트를 덮던 경로를 막았다. `conversation_stopped`가 있으면 109 안전 자원, 입력 disabled, 위기 신호를 유지하고 코칭 요청을 보내지 않는다. **검증: `PLAYWRIGHT_PORT=5193 npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1` 2 passed, `npm run typecheck`.**
- [x] **운영 티켓·헬스 샘플 이력 저장 모델 1차**`/admin` 운영 홈과 `/admin/tickets``app.admin_health_event`·`app.support_ticket` 실제 DB 모델에 연결됐다. `/users/support-tickets`는 인증 사용자의 문제·불만·장애 접수를 저장하고, `/admin/tickets`는 미해결 큐·우선순위·상태 변경을 처리한다. `/admin/uptime`은 상시 SLA가 아니라 관리자 콘솔이 관측한 헬스 샘플 정상률/중단 이력을 반환한다. 가짜 티켓 수·가짜 다운타임 숫자는 계속 금지. **검증: `python -m pytest app/test_admin_ops.py -q` 3 passed, `npm run generate:api-types`, `npm run typecheck`.**
- [x] **사용자별 운영 티켓 조회 UI** — (2026-06-28 처리) 학습자 Settings에 `지원 요청` 섹션을 추가해 본인이 접수한 티켓의 상태·카테고리·우선순위·담당 그룹·해결 메모를 읽기 전용으로 확인한다. `GET /users/support-tickets``reporter_id = current user` 기준으로만 조회하고, 본문과 신고자 이메일/이름은 응답·감사 검증에서 제외한다. **검증: `python -B -m pytest app/test_user_support_tickets.py app/test_admin_ops.py app/test_runtime_policy.py -q` 29 passed, `npm run generate:api-types`, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/settings.spec.ts --project=chromium-desktop --workers=1` 7 passed, `npx playwright test e2e/admin.spec.ts --project=chromium-desktop --workers=1 --grep "operation tickets"` 1 passed.**
- [x] **상시 synthetic monitor/cron 샘플러 1차** — (2026-06-28 처리) `/admin/health`와 같은 헬스 계산을 `record_admin_health_sample()`로 공통화하고, 브라우저 세션 없이 `scripts/record-admin-health-sample.py``app.admin_health_event`에 서비스별 샘플을 append한다. Windows 예약 실행은 `scripts/install-health-sampler-task.ps1``VignetteAdminHealthSampler` Scheduled Task로 설치한다. 이 값은 SLA가 아니라 synthetic sampler가 관측한 최근 샘플 이력이다. **검증: `python -B -m py_compile apps/api/app/routes/admin.py apps/api/app/test_admin_ops.py scripts/record-admin-health-sample.py`, `python scripts/record-admin-health-sample.py --help`, `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\install-health-sampler-task.ps1 -PrintOnly -IntervalMinutes 7`, `python -B -m pytest app/test_admin_ops.py app/test_runtime_policy.py app/test_user_support_tickets.py -q` 31 passed, 로컬 one-shot `recorded_count=5`, `status=ok`, `engine_mode=claude_cli`.**
@ -99,10 +124,10 @@
- 학습자 개인화 대시보드 1차: 교수자 성장 집계와 중복되던 점수/라포/기법 계산을 `app.services.session_metrics`로 공용화하고, `GET /sessions/dashboard`가 본인 세션 기반 `overview/growth/persona_progress/achievements/recent_feedback`를 반환한다. 학습자 홈은 누적 회기, 리뷰 대기, 최근 평가, 라포 흐름, 페르소나별 진행, 최근 피드백, 마지막 세션 리캡을 실제 서버 데이터로 표시한다. 성취는 공식 등급/수료가 아니라 실제 연습 milestone만 사용한다. 2026-06-29 refactor-governance 패스에서 `session_metrics.turn_technique_label()`이 DB 재수화 계약의 `label_ko`를 우선하고 기존 `label/name/id/code` fallback을 유지하도록 고정했다. **검증: `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_evaluation_persistence.py app/test_evaluator_model_routing.py app/test_session_turn_persistence.py app/test_rbac_idor.py app/test_teacher_dashboard.py app/test_learner_dashboard.py -q` 59 passed, 기존 `npm run check:api-types`, `npm run typecheck`, `npm run build`, `learner.spec.ts` 6 passed, `session-review.spec.ts` 2 passed, `teacher.spec.ts` 4 passed, `layout-visual-gate.spec.ts` 7 passed, `session-layout.spec.ts` 4 passed.**
- 페르소나 스튜디오/RAG 저작 10차: 교수 콘솔의 JSON 패널을 분리해 teacher/admin 전용 `/teach/personas` 3열 스튜디오(좌: 저작 흐름·카탈로그·검수 큐, 중앙: RAG 자료 등록·항목형 편집 탭, 우: 검증·RAG 근거·검수 결정)로 옮겼다. 2026-06-27 UI 정리에서 중복 네이티브 파일 입력을 커스텀 SSOT 첨부 드롭존으로 교체하고, 저작 스테퍼·탭별 작성 가이드·검증 dot 정렬을 보강했다. 2026-06-28 항목형 목록 UI 정리에서 자동사고, 회기 시나리오, 말투 filler/verbal tic/nonverbal cue, 역린·금기 응답·금기어를 행 추가/삭제 UI로 전환했고, 저장 payload는 기존 배열 schema를 유지하며 빈 항목만 저장 직전에 제거한다. 프롬프트 탭은 raw JSON textarea 대신 L1 카드·인적 범주·임상 배경·말투·수치 파라미터·역린·회기 시나리오·추가 계약 섹션으로 같은 draft 데이터를 라벨형 검토 UI에 표시한다. 레이아웃 근거와 생성 시안은 `docs/ops/layout-research-2026-06-27/persona-dashboard-layout-guideline.md`에 묶었다. `POST /personas/sources`는 첨부/붙여넣기 자료를 PII 마스킹 후 raw 원문 hash-only 증거를 `kb.raw_source_artifact`에 따로 기록하고, sanitized 파생본만 `kb.source/document/chunk`에 evaluator 전용(`visible_to=['evaluator']`, `sensitivity=2`) 근거 문서로 등록한다. `rag.index_document()``sensitivity=3` 또는 raw marker chunk를 DB 접근 전에 차단해 raw 원문이 `kb.chunk`/embedding/FTS에 들어가지 않게 한다. `POST /personas/drafts/generate``source_id` 기반 RAG 검색 결과만 생성 프롬프트에 넘긴다. 생성 응답과 draft `source_provenance`에는 source id, doc/content hash, chunk id, prompt bundle id/version/hash(`persona-draft-rag@2026-06-28.1`)를 남긴다. P1~P7 시스템 페르소나는 DB 저작 카탈로그의 초기 부트스트랩으로 승격했고, `materialize_seed_personas()`는 누락분만 insert해서 교수 편집본이나 `archived` 보관본을 덮어쓰지 않는다. `scripts/materialize-persona-seeds.py`는 같은 seed/version manifest를 dry-run 기본으로 보고하며, `--apply`일 때만 DB pool을 초기화한 뒤 기존 idempotent materializer를 호출한다. `scripts/sync-persona-sources.py``app.services.source_pack_sync`는 repo-managed source pack의 active `content_hash`를 DB에서 비교하고, 변경 시 `kb.document.version`을 최신+1로 색인한다. 교수자는 공개 목록에서 승인본을 다음 버전 draft로 복제해 수정하고, 불필요한 페르소나는 기존 회기 FK 보존을 위해 같은 code family 전체를 `archived`로 보관 처리한다. `app/persona_read_model.py`는 catalog/review/draft/source/evidence DTO와 mapper를 route에서 분리해 schema 이름과 `source/degraded` fail-closed 경계를 유지한다. 암호화 blob/vault 기반 원문 실저장, 임상팀 최종 검수/서면 evidence, 루브릭·이론 콘텐츠 외부화는 후속. **검증: `python -B -m py_compile app/services/rag.py app/routes/personas.py app/routes/kb.py app/test_persona_review.py app/test_live_coach_sources.py`, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_live_coach_sources.py -q` 38 passed, `python -B -m py_compile app/persona_read_model.py app/routes/personas.py`, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py -q` 33 passed, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_session_turn_persistence.py -q` 55 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `py -3.11 scripts\materialize-persona-seeds.py --json` dry-run `manifest_count=7`, `py -3.11 scripts\sync-persona-sources.py --help`, `npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1` 8 passed, 프롬프트 탭 포함 `layout-visual-gate.spec.ts` 9 passed, `session-layout.spec.ts` desktop/mobile 8 passed.**
- 페르소나 생성 계약 11차 + 평가 저장 packet: `app/persona_generation_contract.py`가 draft structured schema, prompt bundle, legacy/structured `GenerateResponse` payload extraction, generated draft defaults/coercion을 소유한다. 기존 `routes/personas.py`는 teacher/admin gate, RAG evidence 수집, engine request, provenance 조립, DB/HTTP error mapping을 계속 담당한다. `SessionEvaluationWrite.from_result()/from_error()`는 session evaluation 저장 packet 생성을 소유해 route의 long keyword bag을 제거했다. Node conformance runner는 `StreamPacket.oneOf`, payload `$ref`, `gateway-default` request sentinel과 resolved response model 분리를 읽어 event/payload/default-routing 검증 중복을 줄였고, `GatewayPromptParts`는 current-turn prompt split 경계를 명명했다. **검증: persona contract+review 39 passed, backend focused 120 passed, Node conformance OK, gateway contract 27 passed, `npm run check:api-types`, `npm run typecheck`.**
- M1 비언어 이벤트 4차: voice learner turn에 이미 저장하던 `audio_ref`/`silence_ms`/`speech_rate`/`barge_in`을 리뷰 API `nonverbal` 이벤트로 노출하고, 회기 리뷰 축어록에 침묵·발화 속도·끼어듦·음성 입력 칩을 표시했다. 2차에서는 `app.turns.provider_events JSONB``TurnRecord.provider_events`를 추가해 WebSocket control/STT provider 이벤트를 allowlist·size limit 후 보존했고, 3차에서는 저장 전 sanitizer에서 내부 taxonomy `event_type`/`category`를 붙인다. 4차에서는 인증된 회기 리뷰에 한숨·울음·웃음·호흡·운율·배경소음 계열만 한글 label/detail 칩으로 파생 노출한다. raw transcript/text/provider/source/raw type은 응답에서 제외하고, 공개 공유 카드에는 축어록과 provider raw를 싣지 않는다. 실제 provider 기반 한숨·울음 감지, 역량 지표화 정책, live 마이크/WSS 장시간 실측은 후속. **검증: `python -B -m py_compile app/routes/sessions.py app/test_session_turn_persistence.py`, `python -B -m pytest -p no:cacheprovider app/test_voice_ws.py app/test_session_turn_persistence.py app/test_voice_service.py -q` 45 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1` 3 passed.**
- M1 비언어 이벤트 4차: voice learner turn에 이미 저장하던 `audio_ref`/`silence_ms`/`speech_rate`/`barge_in`을 리뷰 API `nonverbal` 이벤트로 노출하고, 회기 리뷰 축어록에 침묵·발화 속도·끼어듦·음성 입력 칩을 표시했다. 2차에서는 `app.turns.provider_events JSONB``TurnRecord.provider_events`를 추가해 WebSocket control/STT provider 이벤트를 allowlist·size limit 후 보존했고, 3차에서는 저장 전 sanitizer에서 내부 taxonomy `event_type`/`category`를 붙인다. 4차에서는 인증된 회기 리뷰에 한숨·울음·웃음·호흡·운율·배경소음 계열만 한글 label/detail 칩으로 파생 노출한다. 이번 보강에서는 `/health`·runtime readiness·deploy preflight가 음성 메타 5개 컬럼 전체를 확인하고, 실제 Session 마이크 UI가 `audio_end`에 browser voice activity/trailing silence 메타를 보내며, DB-backed voice WS/UI E2E가 `/review` `nonverbal` 칩까지 이어지는지 확인한다. raw transcript/text/provider/source/raw type은 응답에서 제외하고, 공개 공유 카드에는 축어록과 provider raw를 싣지 않는다. 실제 provider 기반 한숨·울음 감지, 역량 지표화 정책, live 마이크/WSS 장시간 실측은 후속. **검증: `python -X utf8 -m pytest -p no:cacheprovider app/test_runtime_policy.py app/test_session_turn_persistence.py app/test_voice_ws.py app/test_voice_service.py -q` 77 passed, `PLAYWRIGHT_PORT=5197 npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run --workers=1` 2 passed, `PLAYWRIGHT_PORT=5199 npx playwright test e2e/session-persistence.spec.ts --project=chromium-single-run --workers=1` 5 passed, `npm run typecheck`, preflight skip-db PASS.**
- M2 다회기 케이스 아크 11차: DB 세션 생성 시 `(persona_id, learner_id)` 기준 `case_profile`을 upsert하고, `session_no`를 트랜잭션 안에서 원자 증가시키며, `InProcSession.case_id`가 매회 새 `runtime_case_id`가 아니라 안정 `case_id`를 가리키게 수정했다. 시작/submit/stream/voice 경로는 case recall cache를 사용한다. 세션 종료 시 마스킹 축어록 기반 fallback `session_summary.digest``case_profile.case_digest`, `rapport_trajectory`, `alliance_level`을 갱신하고, 다음 회기 seed recall에서 `case_digest`·직전 `session_summary`·client-visible non-contradicted `pinned_fact`를 함께 조립한다. 3차에서는 마스킹된 client-visible 발화에서 `[NAME]`/`[ORG]` identity와 명시적 상담 약속만 보수적으로 `pinned_fact`에 upsert했고, 4차에서는 삽입 또는 값 변경 시 `pinned_fact_history`에 append-only 이력을 남긴다. 5차에서는 명시적 상담 약속 철회/부정만 기존 non-locked `agreement:counseling` fact를 `contradicted`로 격리하고 history reason `contradiction`을 남긴다. 6차에서는 세션 종료 저장 성공 뒤 마스킹된 client-visible 내담자 발화만 `app.turn_embedding`에 BGE-M3 dense/sparse로 `ON CONFLICT (turn_id) DO NOTHING` 색인한다. 7차에서는 submit/stream 공통 `_prepare_turn_context()` 경계를 만들고 DB seed recall·pinned fact가 다음 턴 EngineMessage L2/L4에 raw 이름 마스킹 상태로 주입되는 route-level 회귀를 추가했다. 8차에서는 `SessionDigestInput`/`SessionDigestResult`/`SessionSummaryWrite`로 종료 digest 입력·fallback 결과·DB write 인자 경계를 명시했다. 9차에서는 `DigestQualityAssessment`/`SessionDigestWorkerOutcome`로 LLM digest 후보 local quality harness를 추가해 빈/짧은 digest, raw forbidden substring, 내부 평가·CCD·상태 marker, 잘못된 `S{session_no}:` prefix를 fallback 유지 대상으로 판정한다. 10차에서는 `session_digest_worker.py``CompressionJob``GenerateRequest` 변환, 주입형 engine/audit 호출, accepted-only `session_summary.digest/compressed_by/token_count``case_profile.case_digest` 적용 계획, one-shot DB loader를 소유한다. 11차에서는 `scripts/run-session-digest-worker.py` dry-run/apply runner와 `SESSION_DIGEST_WORKER_ENABLED=false` 기본값의 세션 종료 background scheduler 골격을 추가하고, `compressed_by IS NULL` loader/apply CAS로 이미 압축된 세션 재실행 race를 막는다. LLM digest worker 입력 후보는 client-visible `text_masked` turn과 open thread로 제한하고, 종료 상태 수치·평가 payload·CCD·raw text는 압축 prompt에 넣지 않는다. `/sessions/{id}/end``digest_pending`은 CompressionJob 생성 여부를 알리는 비동기 압축 필요 신호로 유지한다. 새 contradicted fact는 임의 생성하지 않고, 같은 값 재확인은 history를 늘리지 않으며, `locked` fact는 건드리지 않는다. learner-owned `case_profile` 기준 RLS insert/update를 추가했고, 관계갈등·위기·임상 추론은 자동 pinning/모순 처리에서 제외했다. 관계·임상 fact 승격 기준, 실 provider 장시간 운영, 임상 골든셋 품질평가, 재압축은 후속. **검증: `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_session_digest_worker.py app/test_session_memory.py -q` 30 passed, `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_session_digest_worker.py app/test_session_memory.py app/test_session_turn_persistence.py app/test_orchestrator_masking.py app/test_runtime_policy.py -q` 87 passed, `py -3.11 -X utf8 -B -m py_compile app/services/session_digest_worker.py app/services/memory.py app/test_session_digest_worker.py app/test_session_memory.py`, 기존 `py -3.11 -X utf8 -B -m pytest -p no:cacheprovider app/test_session_memory.py -q` 20 passed, 과거 `py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_session_memory.py app/test_session_turn_persistence.py app/test_orchestrator_masking.py app/test_runtime_policy.py -q` 74 passed, `npm run check:api-types`, `npm run typecheck`.**
- C1 사례개념화 워크시트 4차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 카드가 탐색 11항목·호소 5영역·인지삼제/감정·보호/방해·생물심리사회 목표 초안을 축어록 근거 기반으로 제공한다. 학습자가 편집한 저장본은 `PUT /sessions/{id}/review/worksheet``app.case_worksheet`에 저장되고, 이후 `GET /review``saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. 2026-06-28에는 `data/rubrics/case-worksheet-rubric.json`, schema, loader/validator service, `scripts/check-case-worksheet-rubric.py`를 추가해 임상팀 확정 루브릭을 외부 JSON으로 받을 scaffold를 만들었다. 현재는 `status=scaffold_only`, `scoring_enabled=false`이며 5개 section/28개 item key와 sha256 provenance만 검증한다. 64차 refactor-governance 패스에서 워크시트 템플릿 key source를 `CASE_WORKSHEET_SECTION_SPECS`/`case_worksheet_template_item_keys()`로 명시해 CLI/test가 더미 턴 없이 같은 생성 spec을 검증하게 했다. 73차에서는 기존 `app.session_review_status``worksheet_status`/`worksheet_note`/`worksheet_reviewed_at`을 추가하고, `PUT /teacher/sessions/{id}/review-status`와 교수자 `SessionReview` 카드에서 승인·수정요청·반려 수동 판정을 저장/표시한다. 임상팀 확정 루브릭 콘텐츠, AI 추출/채점 적용·calibration, 승인 후 잠금·재제출 정책, 재귀학습 데이터셋 approved 연계는 후속 gate다. **검증: `py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_session_turn_persistence.py app/test_teacher_dashboard.py app/test_rbac_idor.py app/test_case_worksheet_rubric.py -q` 40 passed, `py -3.11 -X utf8 scripts/check-case-worksheet-rubric.py --json` PASS, `py -3.11 -X utf8 -m py_compile` focused files, `npm run generate:api-types`, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1` 3 passed, 로컬 API smoke 세션 생성→저장→`GET /review` `saved_by_learner:local smoke saved worksheet`.**
- C3 이론모드 2차: `theory_mode``TurnContext`/sessions/voice/evaluator뿐 아니라 `build_turn_messages`의 엔진 메시지까지 전달된다. 프론트는 `persona.theory_target` 기준 기본값을 유지하되, 세션 시작 전 `humanistic`/`cbt`/`integrative` segmented control로 학습자가 명시 선택하고 `POST /sessions``theory_mode`로 보낸다. CBT 체인·이론부합 루브릭은 후속. **검증: `python -B -m pytest -p no:cacheprovider app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` 31 passed, `npm run typecheck`, `npm run build`, `npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --workers=1` 4 passed, `npx playwright test e2e/session-layout.spec.ts --project=chromium-mobile --workers=1` 4 passed, `npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1` 1 passed, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 9 passed.**
- C3 이론모드 2차: `theory_mode``TurnContext`/sessions/voice/evaluator뿐 아니라 `build_turn_messages`의 엔진 메시지까지 전달된다. 프론트는 `persona.theory_target` 기준 기본값을 유지하되, 세션 시작 전 `humanistic`/`cbt`/`integrative` segmented control로 학습자가 명시 선택하고 `POST /sessions``theory_mode`로 보낸다. 2026-07-01에는 교수자 수동 session/turn 재평가가 persona 기본 `theory_target`보다 session `theory_mode`를 우선하고, turn 재평가 `TurnContext`에도 같은 값을 채우도록 보강했다. CBT 체인·이론부합 루브릭은 후속. **검증: `python -B -m pytest -p no:cacheprovider app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` 31 passed, `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_eval_routes.py -q` 5 passed, `C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -X utf8 -m pytest -p no:cacheprovider app/test_eval_routes.py app/test_notifications.py app/test_session_turn_persistence.py -q` 39 passed, `npm run typecheck`, `npm run build`, `npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --workers=1` 4 passed, `npx playwright test e2e/session-layout.spec.ts --project=chromium-mobile --workers=1` 4 passed, `npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1` 1 passed, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 9 passed.**
- M3 인증 claim 1차: Google/SAML/dev-login이 `AUTH_EMAIL_COHORT_MAP`·`AUTH_DOMAIN_COHORT_MAP` 및 SAML cohort claim을 `cohort_ids`로 전달하고, DB `app_user.external_id`는 provider subject(`google:`/`saml:`/`dev:`) 기반으로 저장한다. 운영 SAML 서명검증, 기관 claim schema/test tenant, deprovisioning audit은 후속. **검증: `pytest app/test_auth_providers.py -q` 30 passed, `pytest app/ -q` 178 passed.**
- X2 예산 경고/저비용 평가 라우팅/evaluator cache 관측·일별 비용 추이·모델별 비용 검증 리포트 2차: `ADMIN_USAGE_BUDGET_USD` 설정값을 기준으로 `GET /admin/usage``budget.status=disabled|ok|warn|exceeded`, 사용률, 잔여 예산을 반환하고 관리자 콘솔이 예산 상태 배너를 표시한다. 80% 이상 warn, 100% 이상 exceeded. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL` 설정 시 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달하고, 비워두면 기존 gateway default 라우팅을 유지한다. fast/deep evaluator structured 결과는 canonical request SHA-256 기반 인메모리 semantic cache로 재사용하며, 원문 prompt·completion은 저장하지 않고 성공 파싱 결과만 TTL/entry 제한 안에서 캐시한다. `/admin/usage`와 관리자 `AI 비용` 카드는 cache enabled/entries/hits/misses/stores/evictions/requests/hit_rate와 일별 `daily_cost` 추이를 표시하되 cache key·prompt·completion은 노출하지 않는다. `app.services.usage_report``scripts/report-ai-usage.py`는 동일 usage JSON에서 provider/model별 cost share, token share, cost/turn, cost/1k tokens, metered coverage, budget/cache warning을 산출한다. 자동 차단·한도 enforcement 정책은 후속. **검증: `python -B -m py_compile app/services/usage_report.py app/test_usage_report.py ..\..\scripts\report-ai-usage.py`, `python -B -m pytest -p no:cacheprovider app/test_usage_report.py app/test_evaluator_model_routing.py app/test_runtime_policy.py app/test_admin_ops.py -q` 42 passed, `scripts/report-ai-usage.py` sample CLI schema `vignette.ai_usage_model_cost_report.v1` 생성, prior `npm run check:api-types`, `npm run typecheck`, `npm run build`, admin E2E evidence remains valid because API response shape was not changed.**
- H4 LLM call audit/마스킹 2차: 상담 생성(generate/stream)·fast-loop 평가·deep-loop 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않고 감사 실패는 상담 루프를 막지 않는다. 2026-06-28에는 한국어 이름/기관 로컬 휴리스틱 마스킹 1차를 추가했고, fast evaluator prompt의 내담자 응답과 client turn `text_masked`도 마스킹본을 쓰게 보강했다. 이어서 `app.services.pii_masking_eval`, `data/privacy/pii-masking-ko-fixtures.json`, `scripts/evaluate-pii-masking.py`로 합성 fixture 15케이스 평가 harness를 추가했고, `data/privacy/pii-masking-eval-input.schema.json``data/privacy/pii-masking-eval-report.schema.json`으로 source/category/severity metadata와 summary-only `technical_dry_run` report 계약을 고정했다. 66차에서는 `제 이름은 김서연입니다`, `보호자 이름은 박민수입니다`, `저는 최하늘입니다` 자연 발화형 이름 라벨·자기소개 케이스와 `이름은 중요하지 않다` negative control을 추가해 라벨 단어만 마스킹하고 실명을 남기던 구멍을 막았다. 70차에서는 `guardrail.mask_pii()` 내부에 선택형 한국어 PII recognizer adapter 경계를 추가했다. adapter는 import-time hard dependency가 아니며 명시 등록 전에는 비활성이고, 실패해도 기존 regex fallback이 마지막 안전망으로 유지된다. fake adapter 테스트는 regex가 못 잡는 별명/기관 span을 `[NAME]`/`[ORG]`로 마스킹하고 같은 문장의 전화번호는 후단 regex가 `[PHONE]`으로 처리하는지, adapter 실패 시에도 fallback이 유지되는지 검증한다. span 중복 처리는 문자별 set 할당 대신 정렬된 `last_end` 비교로 정리했다. 기본 CLI JSON은 `masked_text`/`forbidden_remaining` 원문 증거를 제외하며, `소속`/`안내`/`이름` NAME 오탐도 stopword로 보정했다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다(session `2460d56c-a9cb-4a40-a175-9575d510a5e9`). 공개 Google OAuth 실제 `/turn` proof는 별도 B2 항목에 남긴다. **검증: `py -3.11 -X utf8 -m py_compile apps/api/app/services/guardrail.py apps/api/app/services/pii_masking_eval.py apps/api/app/test_pii_masking_eval.py apps/api/app/test_orchestrator_masking.py scripts/evaluate-pii-masking.py`, `py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py -q` 47 passed, `py -3.11 -X utf8 scripts/evaluate-pii-masking.py --json` 15/15 pass, 과거 전체 기준 `pytest app/ -q` 119 passed.**

View file

@ -222,8 +222,16 @@ async def check_database(database_url: str, *, require_app_role: bool) -> Check:
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'
@ -280,7 +288,7 @@ async def check_database(database_url: str, *, require_app_role: bool) -> Check:
"has_turns",
"has_session_review_status",
"has_persona_triggers",
"has_turn_provider_events",
"has_turn_voice_metadata_columns",
"has_session_review_worksheet_columns",
"has_notification_delivery_queue_index",
"has_session_write_policies",

View file

@ -28,6 +28,7 @@ EXPECTED_OWNER_COLUMN_COUNTS = {
"decide": 0,
"ext": 2,
}
EXPECTED_CRITICAL_CARD_COUNT = 3
EXPECTED_DECISION_DETAIL_ROWS = 0
EXPECTED_EXTERNAL_GATE_ROWS = 6
@ -42,6 +43,11 @@ REQUIRED_SHARED_PHRASES = (
"owner 결정 7건은 2026-06-30 전건 확정",
"결정 0개(전건 확정)",
"MASTERPLAN_REVISIONS F-10 승인",
"Public API <b>recovered OK</b>",
"error code 1033",
"Docker Desktop/DB 재기동",
"watchdog healthy: engine, api, web-preview, cloudflared, public-api",
"dev-dashboard E2E redteam 10 passed",
)
FORBIDDEN_STALE_PHRASES = (
@ -65,6 +71,9 @@ FORBIDDEN_STALE_PHRASES = (
"블로커 2 · 결정 3 · 외부조율 2",
"decisions 3건(owner 결정)",
"결정 3개",
"Public API <b>prod OK</b>",
"Public API <b>530 BLOCK</b>",
"API: prod/dev OK",
)
@ -148,6 +157,13 @@ def _planned_cards(dashboard: str) -> list[str]:
)
def _has_check_status_markup(dashboard: str) -> bool:
return bool(
re.search(r'<[^>]+class="[^"]*\btask-status\b[^"]*\bs-check\b[^"]*"', dashboard)
or re.search(r'>\s*CHECK\s*<', dashboard)
)
def run_checks(paths: argparse.Namespace) -> dict[str, Any]:
dashboard = _read(paths.dashboard)
source_gaps = _read(paths.source_gaps)
@ -172,7 +188,7 @@ def run_checks(paths: argparse.Namespace) -> dict[str, Any]:
checks.append(
Check(
name="no_doing_cards",
passed=doing_count == 0 and ">CHECK<" not in dashboard and "s-check" not in dashboard,
passed=doing_count == 0 and not _has_check_status_markup(dashboard),
detail=f"doing={doing_count}",
)
)
@ -213,6 +229,15 @@ def run_checks(paths: argparse.Namespace) -> dict[str, Any]:
)
)
critical_card_count = dashboard.count('class="critbadge"')
checks.append(
Check(
name="critical_card_count",
passed=critical_card_count == EXPECTED_CRITICAL_CARD_COUNT,
detail=f"expected={EXPECTED_CRITICAL_CARD_COUNT} actual={critical_card_count}",
)
)
decision_detail_rows = structure.decision_panel_rows
checks.append(
Check(

View file

@ -0,0 +1,88 @@
from __future__ import annotations
import argparse
import importlib.util
import sys
import unittest
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
CHECKER_PATH = REPO_ROOT / "scripts" / "check-dev-dashboard-ssot.py"
def _load_checker():
spec = importlib.util.spec_from_file_location("check_dev_dashboard_ssot", CHECKER_PATH)
if spec is None or spec.loader is None:
raise RuntimeError(f"Cannot load {CHECKER_PATH}")
module = importlib.util.module_from_spec(spec)
sys.modules[spec.name] = module
spec.loader.exec_module(module)
return module
checker = _load_checker()
def _default_args() -> argparse.Namespace:
return argparse.Namespace(
dashboard=REPO_ROOT / "docs" / "dev_dashboard.html",
source_gaps=REPO_ROOT / "docs" / "guides" / "source-docs-and-gaps.md",
backlog=REPO_ROOT / "docs" / "ops" / "backlog-2026-06-26.md",
testing=REPO_ROOT / "docs" / "guides" / "testing.md",
local_development=REPO_ROOT / "docs" / "guides" / "local-development.md",
)
class DevDashboardSsotTests(unittest.TestCase):
def test_current_dashboard_matches_ssot_contract(self) -> None:
report = checker.run_checks(_default_args())
failed = [check for check in report["checks"] if not check["passed"]]
self.assertEqual([], failed)
self.assertTrue(report["passed"])
self.assertEqual({"done": 25}, report["status_counts"])
def test_structure_parser_counts_nested_owner_columns(self) -> None:
parser = checker._dashboard_structure(
"""
<div data-owner-col="block">
<article data-status="done" data-owner="1"></article>
<article data-status="done" data-owner="0"></article>
<div><article data-status="doing" data-owner="1"></article></div>
</div>
<div data-owner-col="decide">
<article data-status="planned" data-owner="0"></article>
</div>
<div data-owner-col="ext">
<article data-status="done" data-owner="1"></article>
</div>
<div id="panel-decisions">
<span class="task-status s-risk">DECIDE</span>
<span class="task-status s-done">DECIDED</span>
</div>
<span class="task-status s-plan">GATE</span>
"""
)
self.assertEqual({"done": 3, "doing": 1, "planned": 1}, parser.status_counts)
self.assertEqual({"block": 2, "ext": 1}, parser.owner_column_counts)
self.assertEqual(1, parser.decision_panel_rows)
self.assertEqual(1, parser.external_gate_rows)
def test_check_status_detection_ignores_plain_hyphenated_text(self) -> None:
self.assertFalse(checker._has_check_status_markup("<p>readiness-checked metadata</p>"))
self.assertTrue(
checker._has_check_status_markup('<span class="task-status s-check">CHECK</span>')
)
def test_repeated_runs_are_stateless(self) -> None:
reports = [checker.run_checks(_default_args()) for _ in range(3)]
self.assertTrue(all(report["passed"] for report in reports))
self.assertEqual(
[reports[0]["status_counts"], reports[1]["status_counts"], reports[2]["status_counts"]],
[{"done": 25}, {"done": 25}, {"done": 25}],
)
if __name__ == "__main__":
unittest.main()