세션 평가·라이브코치·교수자 분석 라운드 마감 + 문서 정리 + 코드품질 리팩터

- 누적 작업트리 커밋: 회기 평가 복구·durable 저장, 라이브 코치 이력/근거, 교수자 학생분석, 음성 비언어 메타, PII 마스킹, 운영 티켓/헬스 등
- 문서: 완료 기록 docs/archive/ 냉동 보관, docs/ 단일 인덱스(docs/README.md)+통합 TODO(docs/TODO.md)로 정리
- 리팩터(행위 보존): Stage enum SSOT(taxonomy 소유·state_machine re-export), store recent/masked_turns 중복 제거, speaker_ko_label 단일 헬퍼, _list_sessions N+1 제거(state/turns 배치 + 턴평가 하이드레이션 배치)
- 검증: 백엔드 pytest 352 passed, _list_sessions E2E chromium-single-run 2 passed
This commit is contained in:
Yun Chan 2026-07-02 02:50:36 +09:00
parent 7c41c3ce79
commit 778e8526d4
108 changed files with 6457 additions and 455 deletions

View file

@ -1,8 +1,10 @@
# Playwright E2E
These tests exercise the app through the Vite `/api` proxy and a running local
FastAPI server. They do not replace `/auth/me`, `/personas`, sessions, admin, or
review endpoints with Playwright route fixtures.
This directory contains both full-stack E2E tests that exercise the app through
the Vite `/api` proxy and a running local FastAPI server, and focused UI
regression tests that pin states with Playwright route fixtures. Treat a test as
DB-backed/real-API evidence only when the spec does not fulfill the endpoint
being verified and asserts the actual API response or persisted read model.
Required local services:
@ -22,6 +24,14 @@ PLAYWRIGHT_BASE_URL=http://localhost:5173 npm run e2e
VITE_API_BASE=http://127.0.0.1:8000 npm run e2e
```
Feature evidence map:
- `session-persistence.spec.ts` is the primary DB-backed evidence for session runtime flows. It covers browser `openSessionStream()` persistence, Korean PII masking through the browser stream into DB-backed detail/review payloads, crisis learner-only safety persistence without a client AI reply, AI tutor live coach history, source-pack metadata round-trip, voice metadata persistence, learner worksheet/review persistence, session-end evaluation storage, explicit teacher session/turn reevaluation, and manual teacher UI evaluation retry from a real failed row into durable DB state. The AI tutor history test must see `status=ready` and `latency_ms>0`; degraded fallback must not pass as normal engine-backed coaching.
- `kb-source-packs.spec.ts` is DB-backed source-pack sync evidence. It checks admin-only sync and source-scoped evaluator RAG lookup for the licensed source packs; evaluator retrieval 503 is a failure, not a skipped proof.
- `session-review.spec.ts` is route-fixture UI regression evidence for review states, including delayed `평가 대기` to `평가 완료` polling, `평가 실패`, `AI 평가 재시도`, and pre/post input validation states. It does not prove that the evaluator wrote a DB row unless paired with `session-persistence.spec.ts`.
- `teacher.spec.ts` mixes DB-backed teacher console paths with route-fixture queue/readability checks. Use the individual test body before citing it as persisted evidence.
- `session-mvp.spec.ts` is mostly route-fixture UI regression evidence. The AI tutor tests mock normal coaching, stale quota refresh, quota exhaustion over an old card, degraded fallback, history load failure, and runtime persistence source to prove the UI does not show replacement coaching, missing history, stale credits, or temporary storage as a silent success; the voice tests mock the browser microphone/WebSocket to prove both a final transcript followed by `turn_persistence_unavailable` and a voice `conversation_stopped` crisis reply remain visible as failure/safety states instead of successful client turns. These are not DB-backed engine success evidence.
Public Google OAuth `/turn` smoke:
```sh

View file

@ -137,6 +137,7 @@ interface AdminTicketsResponse {
open_count: number;
high_priority_count: number;
stale_count: number;
by_category: Record<string, number>;
};
}
@ -146,6 +147,134 @@ async function expectResponseOk(response: APIResponse | Response) {
}
}
async function mockApprovedAdminWithoutOnboarding(page: Page) {
const seenAdminEndpoints = new Set<string>();
const json = (body: unknown) => JSON.stringify(body);
await page.route("**/api/**", async (route) => {
const request = route.request();
const url = new URL(request.url());
const method = request.method();
const path = url.pathname;
const fulfillJson = (body: unknown, status = 200) =>
route.fulfill({
status,
contentType: "application/json",
body: json(body),
});
if (method === "GET" && path.endsWith("/auth/me")) {
await fulfillJson({
user_id: "stale-admin",
email: "stale-admin@twentyoz.kr",
display_name: "Stale Admin",
role: "admin",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: null,
onboarding_completed_at: null,
nickname: "",
self_introduction: "",
avatar_url: "",
});
return;
}
if (method === "GET" && path.endsWith("/admin/health")) {
seenAdminEndpoints.add("health");
await fulfillJson({
status: "degraded",
environment: "dev",
engine_mode: "openai",
services: [],
});
return;
}
if (method === "GET" && path.endsWith("/admin/users")) {
seenAdminEndpoints.add("users");
await fulfillJson({
source: "database",
durable: true,
users: [],
});
return;
}
if (method === "GET" && path.endsWith("/admin/usage")) {
seenAdminEndpoints.add("usage");
await fulfillJson({
source: "database",
durable: true,
window_days: 7,
total_turns: 0,
metered_turns: 0,
tokens_in: 0,
tokens_out: 0,
cost_usd: 0,
budget: {
limit_usd: 0,
used_ratio: 0,
remaining_usd: null,
status: "disabled",
},
evaluator_cache: {
enabled: false,
entries: 0,
hits: 0,
misses: 0,
stores: 0,
evictions: 0,
requests: 0,
hit_rate: 0,
},
by_provider: [],
daily_cost: [],
});
return;
}
if (method === "GET" && path.endsWith("/admin/uptime")) {
seenAdminEndpoints.add("uptime");
await fulfillJson({
source: "database",
durable: true,
window_hours: 24,
sample_count: 0,
ok_ratio: 0,
degraded_events: 0,
down_events: 0,
last_down_at: null,
});
return;
}
if (method === "GET" && path.endsWith("/admin/tickets")) {
seenAdminEndpoints.add("tickets");
await fulfillJson({
source: "database",
durable: true,
tickets: [],
summary: {
total: 0,
open_count: 0,
high_priority_count: 0,
stale_count: 0,
by_category: {},
},
});
return;
}
await fulfillJson({ detail: `unmocked API request: ${method} ${path}` }, 404);
});
return seenAdminEndpoints;
}
async function signInAsAdmin(page: Page) {
let res: APIResponse | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
@ -451,6 +580,21 @@ async function expectVisibleButtonsFit(page: Page, selector: string, context: st
expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]);
}
test.describe("admin route guards", () => {
test("allows an approved admin without onboarding to open the admin console", async ({ page }) => {
const seenAdminEndpoints = await mockApprovedAdminWithoutOnboarding(page);
await page.goto("/admin");
await expect(page).toHaveURL(/\/admin$/);
await expect(page.getByRole("heading", { name: "현재 서비스 상태" })).toBeVisible();
await expect(page.locator(".ad-status")).toContainText("개발");
await expect
.poll(() => Array.from(seenAdminEndpoints).sort())
.toEqual(["health", "tickets", "uptime", "usage", "users"]);
});
});
test.describe("admin route", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);

View file

@ -0,0 +1,117 @@
import { expect, test, type APIResponse, type Page } from "@playwright/test";
import { completeOnboarding } from "./support";
interface HealthResponse {
db?: boolean;
}
interface SourcePackSyncItem {
source_id: string;
doc_id: number | null;
chunks_indexed: number;
skipped_unchanged: boolean;
embedded: boolean;
degraded: boolean;
}
interface SourcePackSyncResponse {
sources_upserted: number;
chunks_indexed: number;
skipped_unchanged: number;
embedded: boolean;
degraded: boolean;
items: SourcePackSyncItem[];
}
interface EvalGroundingResponse {
chunks: Array<{
source_id?: string | null;
}>;
policy: string;
}
async function expectResponseOk(response: APIResponse) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
}
}
async function signInForSourcePackSync(page: Page, role: "admin" | "learner") {
const response = await page.request.post("/api/auth/dev-login", {
data: {
email: role === "admin" ? "source-pack-admin@twentyoz.kr" : "source-pack-learner@hs.ac.kr",
role,
display_name: role === "admin" ? "Source Pack Admin" : "Source Pack Learner",
},
});
await expectResponseOk(response);
await completeOnboarding(page, {
legal_name: role === "admin" ? "Source Pack Admin" : "Source Pack Learner",
affiliation: "한신대학교",
department: role === "admin" ? "운영" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: role === "admin" ? "010-3333-3333" : "010-4444-4444",
contact_address: "경기도 오산시 한신대학교",
});
}
test.describe("live coach source packs", () => {
test("syncs licensed source packs into DB-backed evaluator RAG with admin-only access @single-run", async ({
page,
}) => {
test.setTimeout(180_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "source pack sync requires DB");
const anonymousSync = await page.request.post("/api/kb/live-coach/source-packs/sync");
expect(anonymousSync.status()).toBe(401);
await signInForSourcePackSync(page, "learner");
const learnerSync = await page.request.post("/api/kb/live-coach/source-packs/sync");
expect(learnerSync.status()).toBe(403);
await signInForSourcePackSync(page, "admin");
const syncResponse = await page.request.post("/api/kb/live-coach/source-packs/sync");
expect(syncResponse.status(), await syncResponse.text()).toBe(202);
const sync = (await syncResponse.json()) as SourcePackSyncResponse;
const sourceIds = new Set(sync.items.map((item) => item.source_id));
expect(sync.sources_upserted).toBeGreaterThanOrEqual(4);
expect(sync.items.length).toBeGreaterThanOrEqual(4);
expect(sourceIds).toContain("workbook_0615_case_conceptualization");
expect(sourceIds).toContain("dsm5tr_case_formulation");
expect(sourceIds).toContain("official_counseling_guideline_seed");
expect(sourceIds).toContain("official_suicide_risk_guidelines");
expect(sync.chunks_indexed).toBeGreaterThanOrEqual(0);
expect(sync.skipped_unchanged).toBeGreaterThanOrEqual(0);
expect(typeof sync.embedded).toBe("boolean");
expect(typeof sync.degraded).toBe("boolean");
for (const item of sync.items) {
expect(typeof item.source_id).toBe("string");
expect(item.source_id.length).toBeGreaterThan(0);
expect(item.doc_id === null || typeof item.doc_id === "number").toBe(true);
expect(item.chunks_indexed).toBeGreaterThanOrEqual(0);
expect(typeof item.skipped_unchanged).toBe("boolean");
expect(typeof item.embedded).toBe("boolean");
expect(typeof item.degraded).toBe("boolean");
expect(item.skipped_unchanged || item.chunks_indexed > 0).toBe(true);
}
const groundingResponse = await page.request.post("/api/kb/eval-grounding", {
data: {
query: "안전계획 보호요인 위기전화",
k: 3,
rerank: false,
source_id: ["official_suicide_risk_guidelines"],
},
});
expect(groundingResponse.status(), await groundingResponse.text()).toBe(200);
const grounding = (await groundingResponse.json()) as EvalGroundingResponse;
expect(grounding.policy).toMatch(/^evaluator:/);
expect(grounding.chunks.length).toBeGreaterThan(0);
expect(grounding.chunks.every((chunk) => chunk.source_id === "official_suicide_risk_guidelines")).toBe(true);
});
});

View file

@ -19,7 +19,7 @@ import {
/**
* Strict visual layout gate.
*
* The layout-redesign handoff (docs/ops/layout-redesign-handoff-2026-06-26.md)
* The layout-redesign handoff (docs/archive/ops/layout-redesign-handoff-2026-06-26.md)
* required a human-style visual acceptance pass across the redesigned screens at
* the suggested breakpoints. This spec hardens that pass into an automated gate:
* every redesigned screen is rendered at every required width, asserted free of
@ -607,8 +607,14 @@ test.describe("layout visual gate @single-run", () => {
await expect(page.locator('[data-learner-analysis-panel="true"]')).toBeVisible({
timeout: 15_000,
});
await page.getByRole("tab", { name: /^전체 회기/ }).click();
await expect(page.locator('[data-learner-session-row="true"]')).toHaveCount(5);
await expect(page.locator('[data-learner-persona-group="true"]')).toHaveCount(2);
const firstPersona = page.locator('[data-learner-persona-group="true"]').first();
const expandButton = firstPersona.getByRole("button", { name: /회기 (펼치기|접기)/ });
if ((await expandButton.getAttribute("aria-expanded")) !== "true") {
await expandButton.click();
}
await expect(page.locator('[data-learner-persona-sessions="true"]')).toBeVisible();
await expect(page.locator('[data-learner-session-row="true"]').first()).toBeVisible();
});
});

View file

@ -4,6 +4,18 @@ const sessionId = "33333333-3333-4333-8333-333333333333";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
declare global {
interface Window {
__voiceErrorFixture?: {
releaseError: () => void;
sent: string[];
};
__voiceCrisisFixture?: {
sent: string[];
};
}
}
function deferred() {
let resolve!: () => void;
const promise = new Promise<void>((next) => {
@ -16,11 +28,19 @@ interface RouteMvpOptions {
endStatus?: number;
endBody?: unknown;
crisisStream?: boolean;
liveCoachStatus?: "ready" | "degraded";
liveCoachHistoryStatus?: number;
liveCoachHistorySource?: "database" | "runtime";
liveCoachPersistenceSource?: "database" | "runtime";
liveCoachHistoryQuotas?: Array<{ remaining: number; max: number }>;
liveCoachSuggestionQuota?: { remaining: number; max: number };
}
async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
const sessionStartRequests: unknown[] = [];
const liveCoachRequests: unknown[] = [];
let liveCoachHistoryRequests = 0;
let deliveredCoachSuggestion: Record<string, unknown> | null = null;
const streamSeen = deferred();
const streamGate = deferred();
@ -62,6 +82,14 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
});
});
await page.route("**/api/voice/health", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ available: true, reason: null }),
});
});
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
@ -198,38 +226,76 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
await page.route(`**/api/sessions/${sessionId}/live-coach`, async (route) => {
const request = route.request();
if (request.method() === "GET") {
const quota =
options.liveCoachHistoryQuotas?.[
Math.min(liveCoachHistoryRequests, options.liveCoachHistoryQuotas.length - 1)
] ?? { remaining: 3, max: 3 };
liveCoachHistoryRequests += 1;
if (options.liveCoachHistoryStatus && options.liveCoachHistoryStatus >= 400) {
await route.fulfill({
status: options.liveCoachHistoryStatus,
contentType: "application/json",
body: JSON.stringify({ detail: "live coach history unavailable" }),
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({ source: "runtime", events: [] }),
body: JSON.stringify({
source: options.liveCoachHistorySource ?? "database",
quota,
credit_events: [],
events: deliveredCoachSuggestion
? [
{
event_id: "fixture-coach-1",
session_id: sessionId,
turn_seq: 1,
stage: "탐색",
created_at: "2026-06-26T00:00:03.000Z",
learner_text_excerpt: learnerText,
client_reply_excerpt: clientReply,
suggestion: deliveredCoachSuggestion,
},
]
: [],
}),
});
return;
}
liveCoachRequests.push(request.postDataJSON());
deliveredCoachSuggestion = {
status: options.liveCoachStatus ?? "ready",
tone: "pos",
focus: "reflection",
title: "감정 반영이 선명합니다",
message: "학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다.",
next_utterance: "그 마음이 가장 크게 올라온 장면을 조금 더 들려줄 수 있을까요?",
rationale:
options.liveCoachStatus === "degraded"
? "AI 코칭 엔진 응답 대신 워크북 규칙과 현재 턴 신호로 만든 대체 판단입니다."
: "방금 발화는 정서 반영과 개방 질문을 함께 포함합니다.",
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: options.liveCoachStatus === "degraded" ? 0 : 12,
persistence_source: options.liveCoachPersistenceSource ?? "database",
quota: options.liveCoachSuggestionQuota ?? { remaining: 2, max: 3 },
credit_events: [],
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
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,
}),
body: JSON.stringify(deliveredCoachSuggestion),
});
});
@ -290,7 +356,277 @@ async function routeMvpApi(page: Page, options: RouteMvpOptions = {}) {
});
});
return { sessionStartRequests, liveCoachRequests, streamGate, streamSeen };
return {
sessionStartRequests,
liveCoachRequests,
streamGate,
streamSeen,
get liveCoachHistoryRequests() {
return liveCoachHistoryRequests;
},
};
}
async function installVoicePersistenceFailureFixture(page: Page, transcriptText: string) {
await page.addInitScript((text) => {
const fixture = {
sent: [] as string[],
socket: null as FixtureWebSocket | null,
releaseError() {
const socket = fixture.socket;
if (!socket || socket.readyState >= FixtureWebSocket.CLOSING) return;
socket.emitJson({
type: "error",
code: "turn_persistence_unavailable",
detail: "voice turn persistence unavailable; retry the utterance",
});
socket.close(1000);
},
};
window.__voiceErrorFixture = fixture;
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
this.readyState = "ended";
},
};
const fakeStream = {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: async () => fakeStream },
});
Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined });
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType = "audio/webm";
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start() {
this.state = "recording";
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
class FixtureWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = FixtureWebSocket.CONNECTING;
binaryType: BinaryType = "blob";
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(_url: string | URL) {
fixture.socket = this;
window.setTimeout(() => {
if (this.readyState !== FixtureWebSocket.CONNECTING) return;
this.readyState = FixtureWebSocket.OPEN;
this.onopen?.(new Event("open"));
this.emitJson({ type: "ready", state: "idle" });
}, 0);
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data !== "string") return;
fixture.sent.push(data);
let payload: { type?: string } = {};
try {
payload = JSON.parse(data) as { type?: string };
} catch {
return;
}
if (payload.type === "audio_end") {
window.setTimeout(() => {
this.emitJson({ type: "state", state: "thinking" });
this.emitJson({ type: "transcript", text, final: true });
}, 0);
}
}
close(code = 1000) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.readyState = FixtureWebSocket.CLOSED;
const event = new Event("close") as CloseEvent;
Object.defineProperty(event, "code", { value: code });
this.onclose?.(event);
}
emitJson(payload: unknown) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
const event = new MessageEvent("message", { data: JSON.stringify(payload) });
this.onmessage?.(event);
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FixtureWebSocket as unknown as typeof WebSocket,
});
}, transcriptText);
}
async function installVoiceCrisisFixture(page: Page, transcriptText: string) {
await page.addInitScript((text) => {
const crisisResource = {
title: "자살예방상담전화 109",
number: "109",
message: "지금은 연습을 멈추고 실제 안전 확인이 먼저입니다.",
};
const fixture = {
sent: [] as string[],
socket: null as FixtureWebSocket | null,
};
window.__voiceCrisisFixture = fixture;
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
this.readyState = "ended";
},
};
const fakeStream = {
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: { getUserMedia: async () => fakeStream },
});
Object.defineProperty(window, "AudioContext", { configurable: true, value: undefined });
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType = "audio/webm";
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start() {
this.state = "recording";
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
class FixtureWebSocket {
static CONNECTING = 0;
static OPEN = 1;
static CLOSING = 2;
static CLOSED = 3;
readyState = FixtureWebSocket.CONNECTING;
binaryType: BinaryType = "blob";
onopen: ((event: Event) => void) | null = null;
onmessage: ((event: MessageEvent) => void) | null = null;
onerror: ((event: Event) => void) | null = null;
onclose: ((event: CloseEvent) => void) | null = null;
constructor(_url: string | URL) {
fixture.socket = this;
window.setTimeout(() => {
if (this.readyState !== FixtureWebSocket.CONNECTING) return;
this.readyState = FixtureWebSocket.OPEN;
this.onopen?.(new Event("open"));
this.emitJson({ type: "ready", state: "idle" });
}, 0);
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data !== "string") return;
fixture.sent.push(data);
let payload: { type?: string } = {};
try {
payload = JSON.parse(data) as { type?: string };
} catch {
return;
}
if (payload.type === "audio_end") {
window.setTimeout(() => {
this.emitJson({ type: "state", state: "thinking" });
this.emitJson({ type: "transcript", text, final: true });
this.emitJson({
type: "reply",
text: "",
safety_flagged: true,
crisis_resource: crisisResource,
conversation_stopped: true,
turn_seq: 1,
});
this.emitJson({ type: "state", state: "idle" });
}, 0);
}
}
close(code = 1000) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
this.readyState = FixtureWebSocket.CLOSED;
const event = new Event("close") as CloseEvent;
Object.defineProperty(event, "code", { value: code });
this.onclose?.(event);
}
emitJson(payload: unknown) {
if (this.readyState === FixtureWebSocket.CLOSED) return;
const event = new MessageEvent("message", { data: JSON.stringify(payload) });
this.onmessage?.(event);
}
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: FixtureWebSocket as unknown as typeof WebSocket,
});
}, transcriptText);
}
test.describe("P1 MVP core loop", () => {
@ -354,6 +690,14 @@ test.describe("P1 MVP core loop", () => {
await expect(
page.getByText("학습자가 내담자의 감정을 평가하지 않고 먼저 되짚었습니다."),
).toBeVisible();
await page.locator(".sx-coach-card").getByRole("button", { name: "근거 보기" }).click();
const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']");
await expect(evidenceDialog).toBeVisible();
await expect(evidenceDialog).toContainText("0615 사례개념화 워크북");
await expect(evidenceDialog).toContainText("reflection · source_pack · 2026-06-15");
await expect(evidenceDialog).toContainText("허가된 요약 근거");
await evidenceDialog.getByRole("button", { name: "닫기" }).click();
await expect(evidenceDialog).toHaveCount(0);
expect(api.liveCoachRequests).toHaveLength(1);
expect(api.liveCoachRequests[0]).toMatchObject({
learner_text: learnerText,
@ -362,6 +706,209 @@ test.describe("P1 MVP core loop", () => {
});
});
test("refreshes stale empty AI tutor quota before blocking coaching", async ({ page }) => {
const api = await routeMvpApi(page, {
liveCoachHistoryQuotas: [
{ remaining: 0, max: 3 },
{ remaining: 1, max: 3 },
{ remaining: 0, max: 3 },
],
liveCoachSuggestionQuota: { remaining: 0, max: 3 },
});
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();
expect(api.liveCoachRequests).toHaveLength(1);
await expect.poll(() => api.liveCoachHistoryRequests).toBeGreaterThanOrEqual(2);
});
test("shows refreshed AI tutor quota exhaustion over a stale coaching card", async ({
page,
}) => {
const api = await routeMvpApi(page, {
liveCoachHistoryQuotas: [
{ remaining: 0, max: 3 },
{ remaining: 0, max: 3 },
],
liveCoachSuggestionQuota: { remaining: 0, max: 3 },
});
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
expect(api.liveCoachRequests).toHaveLength(1);
await page.getByLabel("학습자 발화 입력").fill("그때 마음을 더 자세히 말해줘도 괜찮아요.");
await page.getByRole("button", { name: "보내기" }).click();
await expect(coachCard.getByText("코칭 기회를 모두 사용했습니다.")).toBeVisible();
await expect(coachCard.getByText("감정 반영이 선명합니다")).toHaveCount(0);
expect(api.liveCoachRequests).toHaveLength(1);
});
test("surfaces AI tutor history load failure instead of an empty history", async ({ page }) => {
const api = await routeMvpApi(page, { liveCoachHistoryStatus: 503 });
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
await coachCard.getByRole("button", { name: "이력 보기" }).click();
const historyDialog = page.locator(".sx-coach-history [role='dialog']");
await expect(historyDialog).toBeVisible();
await expect(historyDialog.getByRole("alert")).toContainText("코칭 이력을 불러오지 못했습니다.");
await expect(historyDialog).not.toContainText("아직 이 턴에 저장된 코칭 이력이 없습니다.");
expect(api.liveCoachRequests).toHaveLength(1);
});
test("labels runtime AI tutor persistence as temporary history", async ({ page }) => {
const api = await routeMvpApi(page, {
liveCoachHistorySource: "runtime",
liveCoachPersistenceSource: "runtime",
});
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard.getByText("감정 반영이 선명합니다")).toBeVisible();
await expect(coachCard).toContainText("임시 이력");
await expect(coachCard).toContainText("DB에 확정 저장되지 않아");
await coachCard.getByRole("button", { name: "이력 보기" }).click();
const historyDialog = page.locator(".sx-coach-history [role='dialog']");
await expect(historyDialog).toContainText("임시 저장소 기준");
expect(api.liveCoachRequests).toHaveLength(1);
});
test("marks pending voice transcript as failed when turn persistence fails @single-run", async ({
page,
}) => {
const failedVoiceText = "요즘 잠을 잘 못 자요.";
await routeMvpApi(page);
await installVoicePersistenceFailureFixture(page, failedVoiceText);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: failedVoiceText });
await expect(learnerBubble).toBeVisible();
await expect(learnerBubble).toHaveClass(/is-partial/);
await expect(
page.locator(".sx-utt.is-thinking").filter({ hasText: "답변을 준비 중입니다." }),
).toBeVisible();
await page.evaluate(() => window.__voiceErrorFixture?.releaseError());
await expect(learnerBubble).toBeVisible();
await expect(learnerBubble).toHaveClass(/is-failed/);
await expect(learnerBubble).not.toHaveClass(/is-partial/);
await expect(learnerBubble.getByText("저장 실패 · 다시 시도하세요.")).toBeVisible();
await expect(page.locator(".sx-utt.is-thinking")).toHaveCount(0);
await expect(page.getByRole("alert")).toContainText("음성 발화를 저장하지 못했습니다.");
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await expect(page.locator(".sx-mic-block__l")).toContainText("마이크 오류");
});
test("keeps crisis safety gate visible for a voice conversation stop", async ({ page }) => {
const crisisText = "죽고 싶다는 생각이 자꾸 들어요.";
const api = await routeMvpApi(page);
await installVoiceCrisisFixture(page, crisisText);
await page.goto("/learn/session/P1");
await page.getByRole("button", { name: "회기 시작" }).click();
await page.getByRole("button", { name: "코칭" }).click();
const micButton = page.getByRole("button", { name: "마이크 켜기" });
await expect(micButton).toBeEnabled();
await micButton.click();
await page.getByRole("button", { name: "발화 보내기" }).click();
const learnerBubble = page.locator(".sx-utt.is-learner").filter({ hasText: crisisText });
await expect(learnerBubble).toBeVisible();
await expect(learnerBubble).not.toHaveClass(/is-partial/);
await expect(learnerBubble).not.toHaveClass(/is-failed/);
await expect(page.locator(".sx-crisis-resource").getByText("자살예방상담전화 109")).toBeVisible();
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
await expect(page.getByText("내담자 응답 없음")).toHaveCount(0);
await expect(page.locator(".sx-utt.is-client")).toHaveCount(0);
await expect(page.getByLabel("학습자 발화 입력")).toBeDisabled();
await expect(page.getByRole("button", { name: "마이크 켜기" })).toBeDisabled();
const sent = await page.evaluate(() => window.__voiceCrisisFixture?.sent ?? []);
expect(sent.some((payload) => payload.includes("\"audio_end\""))).toBe(true);
expect(sent.some((payload) => payload.includes("\"close\""))).toBe(true);
expect(api.liveCoachRequests).toHaveLength(0);
});
test("labels degraded AI tutor fallback as replacement coaching", async ({ page }) => {
const api = await routeMvpApi(page, { liveCoachStatus: "degraded" });
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();
const coachCard = page.locator(".sx-coach-card");
await expect(coachCard).toContainText("대체 코칭");
await expect(coachCard).toContainText("AI 응답 대체");
await expect(coachCard).toContainText("AI 코칭 엔진 응답 대신");
expect(api.liveCoachRequests).toHaveLength(1);
await coachCard.getByRole("button", { name: "근거 보기" }).click();
const evidenceDialog = page.locator(".sx-coach-modal [role='dialog']");
await expect(evidenceDialog).toBeVisible();
await expect(evidenceDialog).toContainText("AI 코칭 엔진 응답 대신");
await expect(evidenceDialog).toContainText("0615 사례개념화 워크북");
await evidenceDialog.getByRole("button", { name: "닫기" }).click();
await expect(evidenceDialog).toHaveCount(0);
const coachMark = page.locator(".sx-utt__coach-mark.is-degraded");
await expect(coachMark).toBeVisible();
await expect(coachMark).toHaveAttribute("title", /AI 응답 대체/);
await coachMark.click();
const historyDialog = page.locator(".sx-coach-history [role='dialog']");
await expect(historyDialog).toContainText("AI 응답 대체");
await expect(historyDialog).toContainText("0615 사례개념화 워크북");
});
test("keeps crisis safety gate visible instead of showing empty client reply @single-run", async ({ page }) => {
const api = await routeMvpApi(page, { crisisStream: true });

View file

@ -1,5 +1,11 @@
import { expect, test, type Page } from "@playwright/test";
import { fetchAvailablePersona, signInAsLearner, signInAsTeacher } from "./support";
import {
fetchAvailablePersona,
signInAsAdmin,
signInAsLearner,
signInAsTeacher,
withGlobalEngineConfigLock,
} from "./support";
interface SessionStartResponse {
session_id: string;
@ -20,16 +26,35 @@ interface TeacherSafetyAlert {
interface TeacherDashboardResponse {
source: string;
safety_alerts: TeacherSafetyAlert[];
pending_reviews?: TeacherSessionSummary[];
recent_sessions?: TeacherSessionSummary[];
}
interface TeacherSessionSummary {
session_id: string;
turn_count: number;
evaluation_status?: "pending" | "ready" | "error";
review_ready?: boolean;
supervisor_state?: string;
}
interface ReviewTurn {
speaker: string;
text: string;
techniques?: Array<{
kind?: string;
label: string;
}>;
nonverbal?: Array<{
kind: string;
label: string;
detail: string;
}>;
note?: {
tone?: string;
title: string;
body: string;
} | null;
}
interface SessionReviewResponse {
@ -55,12 +80,24 @@ interface SessionReviewResponse {
} | null;
}
interface SessionDetailResponse {
session_id: string;
theory_mode: string;
status: "active" | "ended";
turns?: Array<{
speaker: "learner" | "client";
text: string;
}>;
}
interface LiveCoachEvent {
turn_seq: number;
learner_text_excerpt?: string | null;
suggestion: {
status?: "ready" | "degraded";
title: string;
message: string;
latency_ms?: number;
sources?: Array<{
source_id: string;
title: string;
@ -111,6 +148,12 @@ interface EvaluationSummaryResponse {
deep?: Record<string, unknown> | null;
}
interface AdminEngineConfigResponse {
engine_mode: string;
engine_url: string;
model: string;
}
interface PrepostMeasureItem {
measure_name: string;
timepoint: string;
@ -420,6 +463,37 @@ async function createSessionWithTurn(page: Page) {
return { sessionId: started.session_id, expectedMinTurns };
}
async function createActiveSessionWithTurn(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);
let turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "제가 이해한 바로는 그 일이 꽤 오래 마음에 남아 있었던 것 같습니다.",
},
});
for (let attempt = 0; attempt < 2 && !turnResponse.ok(); attempt += 1) {
await page.waitForTimeout(1000);
turnResponse = await page.request.post(`/api/sessions/${started.session_id}/turn`, {
data: {
text: "그 장면을 떠올릴 때 몸이나 마음에서 먼저 느껴지는 반응이 있나요?",
},
});
}
await expectResponseOk(turnResponse);
return started.session_id;
}
async function createEndedSessionWithoutTurn(page: Page) {
const persona = await fetchAvailablePersona(page);
const startedResponse = await page.request.post("/api/sessions", {
@ -456,6 +530,49 @@ async function evaluationState(page: Page, sessionId: string) {
}
test.describe("session persistence", () => {
test("persists selected CBT theory mode from session UI into DB-backed detail @single-run", async ({
page,
}) => {
test.setTimeout(60_000);
const healthResponse = await page.request.get("/api/health");
await expectResponseOk(healthResponse);
const health = (await healthResponse.json()) as HealthResponse;
test.skip(!health.db, "theory mode persistence requires DB");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
const cbtButton = page.locator(".sx-theory").getByRole("button", { name: /CBT/ });
await expect(cbtButton).toBeVisible();
await cbtButton.click();
await expect(cbtButton).toHaveAttribute("aria-pressed", "true");
const startResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/api/sessions");
});
await page.getByRole("button", { name: "회기 시작" }).click();
const startResponse = await startResponsePromise;
await expectResponseOk(startResponse);
expect(startResponse.request().postDataJSON()).toMatchObject({
persona_code: persona.code,
theory_mode: "cbt",
});
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 detailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(detailResponse);
const detail = (await detailResponse.json()) as SessionDetailResponse;
expect(detail.session_id).toBe(sessionId);
expect(detail.theory_mode).toBe("cbt");
expect(detail.status).toBe("active");
});
test("persists the browser SSE stream into DB-backed review @single-run", async ({ page }) => {
test.setTimeout(120_000);
@ -511,6 +628,77 @@ test.describe("session persistence", () => {
).toBe(true);
});
test("masks Korean PII through the browser 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, "PII stream masking requires DB and engine");
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const piiText =
"제 이름은 김서연이고 한신대학교 상담센터에서 010-1234-5678로 연락을 받았어요.";
const forbiddenRaw = ["김서연", "한신대학교", "상담센터", "010-1234-5678"];
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(piiText);
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,
});
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 detailResponse = await page.request.get(`/api/sessions/${sessionId}`);
await expectResponseOk(detailResponse);
const detail = (await detailResponse.json()) as SessionDetailResponse;
expect(detail.session_id).toBe(sessionId);
const detailBlob = JSON.stringify(detail);
for (const raw of forbiddenRaw) {
expect(detailBlob).not.toContain(raw);
}
const detailLearnerTurn = detail.turns?.find((turn) => turn.speaker === "learner");
expect(detailLearnerTurn?.text).toContain("[NAME]");
expect(detailLearnerTurn?.text).toContain("[ORG]");
expect(detailLearnerTurn?.text).toContain("[PHONE]");
const reviewResponse = await page.request.get(`/api/sessions/${sessionId}/review`);
await expectResponseOk(reviewResponse);
const review = (await reviewResponse.json()) as SessionReviewResponse;
const reviewBlob = JSON.stringify(review);
for (const raw of forbiddenRaw) {
expect(reviewBlob).not.toContain(raw);
}
const reviewLearnerTurn = review.turns.find((turn) => turn.speaker === "learner");
expect(reviewLearnerTurn?.text).toContain("[NAME]");
expect(reviewLearnerTurn?.text).toContain("[ORG]");
expect(reviewLearnerTurn?.text).toContain("[PHONE]");
});
test("persists crisis safety event into DB-backed teacher dashboard @single-run", async ({
page,
}) => {
@ -552,6 +740,19 @@ test.describe("session persistence", () => {
await expect(page.getByRole("link", { name: "109" })).toBeVisible();
await expect(input).toBeDisabled();
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 === crisisText),
"the crisis utterance itself should be persisted for supervisor review",
).toBe(true);
expect(
review.turns.some((turn) => turn.speaker === "client" && turn.text.trim().length > 0),
"real crisis escalation must stop before a client AI reply is stored",
).toBe(false);
await signInAsTeacher(page);
await expect
.poll(
@ -574,7 +775,9 @@ test.describe("session persistence", () => {
await page.goto("/teach");
await expect(page.getByText("109 안전 확인 큐")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".pf-alert").filter({ hasText: sessionId ?? "" })).toContainText("109");
await expect(page.locator(".pf-alert").filter({ hasText: sessionId ?? "" })).toContainText("109", {
timeout: 30_000,
});
});
test("persists AI tutor coaching history through reload @single-run", async ({ page }) => {
@ -627,6 +830,8 @@ test.describe("session persistence", () => {
const coachResponse = await coachResponsePromise;
await expectResponseOk(coachResponse);
const coachSuggestion = (await coachResponse.json()) as LiveCoachEvent["suggestion"];
expect(coachSuggestion.status).toBe("ready");
expect(coachSuggestion.latency_ms ?? 0).toBeGreaterThan(0);
const workbookSource = coachSuggestion.sources?.find(
(source) => source.source_id === "workbook_0615_case_conceptualization",
);
@ -656,6 +861,8 @@ test.describe("session persistence", () => {
event.suggestion.title === coachSuggestion.title,
);
expect(persistedCoachEvent, "DB-backed live coach history should include the delivered coaching event").toBeTruthy();
expect(persistedCoachEvent?.suggestion.status).toBe("ready");
expect(persistedCoachEvent?.suggestion.latency_ms ?? 0).toBeGreaterThan(0);
expect(
persistedCoachEvent?.suggestion.sources?.some(
(source) =>
@ -1106,6 +1313,19 @@ test.describe("session persistence", () => {
expect(review.supervisorState).toBe("평가 완료");
expect(review.summary ?? "").toContain("평가 AI");
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse;
expect(dashboard.source).toBe("database");
const dashboardSession = (dashboard.recent_sessions ?? []).find(
(session) => session.session_id === sessionId,
);
expect(dashboardSession, "teacher dashboard should expose the same persisted session").toBeTruthy();
expect(dashboardSession?.turn_count ?? 0).toBeGreaterThanOrEqual(expectedMinTurns);
expect(dashboardSession?.evaluation_status).toBe("ready");
expect(dashboardSession?.review_ready).toBe(true);
expect(dashboardSession?.supervisor_state).toBe("평가 완료");
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", {
timeout: 15_000,
@ -1113,4 +1333,206 @@ test.describe("session persistence", () => {
await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨");
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
});
test("persists explicit teacher session reevaluation into durable DB state @single-run", async ({
page,
}) => {
test.setTimeout(180_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, "teacher session reevaluation requires DB and engine");
await signInAsLearner(page);
const sessionId = await createActiveSessionWithTurn(page);
await signInAsTeacher(page);
const reevaluateResponse = await page.request.post(`/api/eval/sessions/${sessionId}/reevaluate`, {
data: { scope: "stage_transition" },
});
await expectResponseOk(reevaluateResponse);
const reevaluation = (await reevaluateResponse.json()) as {
scope?: string;
error?: string | null;
turns_evaluated?: number;
};
expect(reevaluation.scope).toBe("stage_transition");
expect(reevaluation.error ?? "").toBe("");
expect(reevaluation.turns_evaluated ?? 0).toBeGreaterThan(0);
const evaluationResponse = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`);
await expectResponseOk(evaluationResponse);
const evaluation = (await evaluationResponse.json()) as EvaluationSummaryResponse;
expect(evaluation.status).toBe("ready");
expect(evaluation.durable).toBe(true);
expect(evaluation.deep?.scope).toBe("stage_transition");
expect(evaluation.deep?.turns_evaluated).toBe(reevaluation.turns_evaluated);
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.supervisorState).toBe("평가 완료");
});
test("persists explicit teacher turn reevaluation into DB-backed review state @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, "teacher turn reevaluation requires DB and engine");
await signInAsLearner(page);
const sessionId = await createActiveSessionWithTurn(page);
await signInAsTeacher(page);
const turnReevaluationResponse = await page.request.post(`/api/eval/sessions/${sessionId}/turn`, {
data: { turn_seq: 1 },
});
await expectResponseOk(turnReevaluationResponse);
const turnReevaluation = (await turnReevaluationResponse.json()) as {
error?: string | null;
techniques?: Array<{ label_ko?: string; code?: string }>;
};
expect(turnReevaluation.error ?? "").toBe("");
const expectedLabels = (turnReevaluation.techniques ?? [])
.map((technique) => technique.label_ko || technique.code || "")
.filter((label) => label.length > 0);
test.skip(expectedLabels.length === 0, "evaluator returned no technique labels to verify in review");
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");
expect(learnerTurn, "review should still expose the original learner turn").toBeTruthy();
const reviewLabels = (learnerTurn?.techniques ?? []).map((technique) => technique.label);
for (const label of expectedLabels) {
expect(reviewLabels).toContain(label);
}
});
test("runs manual AI evaluation retry from teacher review UI into durable DB state @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, "manual evaluation retry requires DB and engine");
await signInAsLearner(page);
const sessionId = await createActiveSessionWithTurn(page);
const endedResponse = await page.request.post(`/api/sessions/${sessionId}/end`);
await expectResponseOk(endedResponse);
await withGlobalEngineConfigLock("manual-evaluation-retry-ui", async () => {
await signInAsAdmin(page);
const engineResponse = await page.request.get("/api/admin/engine-config");
await expectResponseOk(engineResponse);
const currentEngine = (await engineResponse.json()) as AdminEngineConfigResponse;
try {
const brokenEnginePatch = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: "http://127.0.0.1:9",
model: currentEngine.model,
},
});
await expectResponseOk(brokenEnginePatch);
await signInAsTeacher(page);
const failedReevaluationResponse = await page.request.post(
`/api/eval/sessions/${sessionId}/reevaluate`,
{ data: { scope: "session_end" } },
);
expect(
failedReevaluationResponse.ok(),
"broken engine should create a failed durable evaluation seed",
).toBe(false);
await signInAsAdmin(page);
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
},
});
await expectResponseOk(restoreResponse);
await signInAsTeacher(page);
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("평가 실패")).toBeVisible({ timeout: 15_000 });
const retryButton = page.getByRole("button", { name: "AI 평가 재시도" });
await expect(retryButton).toBeVisible();
const reevaluateResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return (
response.request().method() === "POST" &&
url.pathname.endsWith(`/eval/sessions/${sessionId}/reevaluate`)
);
});
await retryButton.click();
const reevaluateResponse = await reevaluateResponsePromise;
await expectResponseOk(reevaluateResponse);
const reevaluation = (await reevaluateResponse.json()) as {
scope?: string;
error?: string | null;
turns_evaluated?: number;
};
expect(reevaluation.scope).toBe("session_end");
expect(reevaluation.error ?? "").toBe("");
expect(reevaluation.turns_evaluated ?? 0).toBeGreaterThan(0);
await expect(page.locator(".sr-head__stats")).toContainText("평가 완료", {
timeout: 30_000,
});
await expect(page.locator('[aria-label="리뷰 생성 상태"]')).toContainText("준비됨");
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
const evaluationResponse = await page.request.get(`/api/eval/sessions/${sessionId}/evaluation`);
await expectResponseOk(evaluationResponse);
const evaluation = (await evaluationResponse.json()) as EvaluationSummaryResponse;
expect(evaluation.status).toBe("ready");
expect(evaluation.durable).toBe(true);
expect(evaluation.deep?.scope).toBe("session_end");
expect(evaluation.deep?.turns_evaluated).toBe(reevaluation.turns_evaluated);
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.supervisorState).toBe("평가 완료");
const dashboardResponse = await page.request.get("/api/teacher/dashboard");
await expectResponseOk(dashboardResponse);
const dashboard = (await dashboardResponse.json()) as TeacherDashboardResponse;
expect(dashboard.source).toBe("database");
const dashboardSession = [...(dashboard.pending_reviews ?? []), ...(dashboard.recent_sessions ?? [])].find(
(session) => session.session_id === sessionId,
);
expect(dashboardSession, "teacher dashboard should expose the retried session").toBeTruthy();
expect(dashboardSession?.evaluation_status).toBe("ready");
expect(dashboardSession?.review_ready).toBe(true);
expect(dashboardSession?.supervisor_state).toBe("평가 완료");
} finally {
await signInAsAdmin(page);
const restoreResponse = await page.request.patch("/api/admin/engine-config", {
data: {
engine_mode: currentEngine.engine_mode,
engine_url: currentEngine.engine_url,
model: currentEngine.model,
},
});
await expectResponseOk(restoreResponse);
}
});
});
});

View file

@ -242,6 +242,80 @@ test.describe("session review", () => {
expect(reviewAttempts).toBeGreaterThanOrEqual(2);
});
test("keeps polling long-running session evaluation until the ready review arrives", async ({
page,
}) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
const sessionId = "review-delayed-ready";
let reviewAttempts = 0;
const readyAtAttempt = 10;
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
reviewAttempts += 1;
const ready = {
...filledReviewResponse(sessionId),
summary: "평가 AI가 늦게 완료된 deep-loop 결과를 반영했습니다.",
};
const pending = {
...ready,
supervisorState: "평가 대기",
summary: "평가 AI가 긴 회기 축어록을 분석 중입니다.",
rubric: [],
goodMoments: [],
growthPoints: [],
degraded: true,
reviewReady: false,
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(reviewAttempts >= readyAtAttempt ? ready : pending),
});
});
await page.goto(`/learn/session/${sessionId}/review`);
await expect(page.getByText("평가 AI가 늦게 완료된 deep-loop 결과를 반영했습니다.")).toBeVisible({
timeout: 20_000,
});
await expect(page.getByText("평가 완료")).toBeVisible();
expect(reviewAttempts).toBeGreaterThanOrEqual(readyAtAttempt);
});
test("does not offer manual AI retry while session evaluation is still pending", async ({
page,
}) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
const sessionId = "review-pending-no-manual-retry";
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
const pending = {
...filledReviewResponse(sessionId),
supervisorState: "평가 대기",
summary: "평가 AI가 저장된 축어록을 분석 중입니다.",
rubric: [],
goodMoments: [],
growthPoints: [],
degraded: true,
reviewReady: false,
};
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(pending),
});
});
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("평가 대기", { exact: true })).toBeVisible();
await expect(page.getByText("평가 AI가 저장된 축어록을 분석 중입니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toHaveCount(0);
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
});
test("lets supervisor retry a failed AI session evaluation", async ({ page }) => {
await routeSupervisorCapableLearner(page);
await routePrepostMeasures(page);
@ -303,11 +377,13 @@ test.describe("session review", () => {
await page.goto(`/teach/session/${sessionId}/review`);
await expect(page.getByText("평가 실패")).toBeVisible();
await expect(page.getByRole("button", { name: "AI 평가 재시도" })).toBeVisible();
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
await page.getByRole("button", { name: "AI 평가 재시도" }).click();
await expect(page.getByText("평가 완료")).toBeVisible();
await expect(page.getByText("평가 실패")).toHaveCount(0);
await expect(page.getByRole("button", { name: "검토 완료" })).toBeEnabled();
expect(reevaluateRequests).toBe(1);
});
@ -483,5 +559,13 @@ test.describe("session review", () => {
await expect(page.getByText("2/3 쌍")).toBeVisible();
await expect(page.locator(".sr-prepost__actions")).toContainText("저장된 값 기준");
const prepostCard = page.locator(".sr-card--prepost");
await prepostInputs.nth(0).fill("");
await expect(prepostInputs.nth(0)).toHaveAttribute("aria-invalid", "true");
await expect(prepostCard.locator(".sr-prepost__actions")).toContainText("저장되지 않은 입력 있음");
await expect(prepostCard.locator(".sr-prepost__actions")).not.toContainText("저장된 값 기준");
await expect(prepostCard.getByRole("alert")).toContainText("기존 값을 비우려면 새 점수를 입력해 주세요.");
await expect(prepostCard.getByRole("button", { name: "점수 저장" })).toBeDisabled();
});
});

View file

@ -10,8 +10,9 @@ export interface E2EPersona {
}
export async function useRealApi(_page: Page) {
// Intentionally empty. E2E should exercise the local API through the Vite
// proxy instead of replacing app data with browser-side route fixtures.
// No-op marker for tests that should use the configured Vite /api proxy unless
// a spec explicitly installs route fixtures for a focused UI or error state.
// This helper alone is not proof that every test in the file is fixture-free.
}
function sleep(ms: number) {
@ -98,6 +99,25 @@ export async function signInAsTeacher(page: Page) {
});
}
export async function signInAsAdmin(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await completeOnboarding(page, {
legal_name: "E2E Admin",
affiliation: "한신대학교",
department: "운영",
grade_level: "관리자",
phone: "010-2222-2222",
contact_address: "경기도 오산시 한신대학교",
});
}
export async function completeOnboarding(
page: Page,
profile: {

View file

@ -660,7 +660,21 @@ test.describe("teacher console", () => {
const analysisPanel = page.locator('[data-learner-analysis-panel="true"]');
await expect(analysisPanel).toBeVisible();
await expect(analysisPanel.getByRole("tab", { name: /^페르소나별 회기/ })).toHaveAttribute(
"aria-selected",
"true",
);
await expect(analysisPanel.locator('[data-learner-persona-group="true"]')).toHaveCount(2);
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(0);
const personaGroup = analysisPanel
.locator('[data-learner-persona-group="true"]')
.filter({ hasText: "민재" });
await personaGroup.getByRole("button", { name: /P2 민재 회기 펼치기/ }).click();
await expect(analysisPanel.locator('[data-learner-persona-sessions="true"]')).toBeVisible();
await expect(
analysisPanel.locator('[data-learner-persona-sessions="true"]').getByText(`${learnerB}-session-2`),
).toBeVisible();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(1);
await analysisPanel.getByRole("tab", { name: /^전체 회기/ }).click();
await expect(analysisPanel.locator('[data-learner-session-row="true"]')).toHaveCount(2);
await expect(analysisPanel.getByText(`${learnerB}-session-2`)).toBeVisible();
@ -766,6 +780,89 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("surfaces failed AI session evaluation in the teacher pending queue", async ({
page,
}) => {
const sessionId = "teacher-failed-ai-evaluation-session";
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 1,
active_sessions: 0,
ended_sessions: 1,
learner_growth: [],
safety_alerts: [],
pending_reviews: [
{
session_id: sessionId,
learner_id: "00000000-0000-0000-0000-000000000116",
learner_label: "하린",
persona_code: "P6",
persona_name: "하린",
session_no: 6,
status: "ended",
stage: "정리",
turn_count: 18,
learner_turn_count: 9,
client_turn_count: 9,
started_at: "2026-06-30T05:00:00Z",
ended_at: "2026-06-30T06:42:33Z",
review_status: "pending",
review_note: null,
reviewed_at: null,
evaluation_status: "error",
review_ready: false,
supervisor_state: "평가 실패",
evaluation_error: "session evaluation timeout after 45s",
},
],
recent_sessions: [],
message: "교수자 검토 대기 회기가 있습니다.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "00000000-0000-0000-0000-000000000901",
email: "teacher@hs.ac.kr",
role: "teacher",
display_name: "E2E Teacher",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: [],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: "E2E Teacher",
self_introduction: "",
avatar_url: "",
}),
}),
);
await page.goto("/teach");
const row = page.getByRole("button", { name: /하린 P6 회기 상세 검토/ });
await expect(row).toBeVisible();
await expect(row).toContainText("평가 실패");
await expect(row).toContainText("검토 대기");
});
test("opens recent ended and active sessions from the teacher history @single-run", async ({
page,
}) => {

View file

@ -122,4 +122,30 @@ test.describe("voice websocket auth boundary", () => {
});
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
test("rejects an existing voice session after consent withdrawal @single-run", async ({
page,
}, testInfo) => {
await signInLearner(page, testInfo, "withdrawn");
const persona = await fetchAvailablePersona(page);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
expect(start.ok(), await start.text()).toBeTruthy();
const { session_id } = (await start.json()) as { session_id: string };
const withdrawn = await page.request.delete("/api/auth/consent");
expect(withdrawn.ok(), await withdrawn.text()).toBeTruthy();
await page.goto("/login");
const result = await openVoiceSocket(
page,
`/api/voice/ws?session_id=${encodeURIComponent(session_id)}`,
);
const messages = parsedMessages(result);
expect(result.code).toBe(1008);
expect(messages).toContainEqual({ type: "error", detail: "consent_required" });
expect(messages.some((message) => message.type === "degraded")).toBeFalsy();
});
});

View file

@ -17,7 +17,7 @@
import type { ReactNode } from "react";
import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom";
import { AuthProvider, canAccessRole, useAuth, roleHomePath, type Role } from "./lib/auth";
import { AuthProvider, canAccessRole, useAuth, roleHomePath, type AuthUser, type Role } from "./lib/auth";
import Login from "./pages/Login";
import Onboarding from "./pages/Onboarding";
@ -74,6 +74,17 @@ function RequireAuth({
return <>{children}</>;
}
function isAdminWorkspacePath(path: string) {
return path === "/admin" || path.startsWith("/admin/");
}
function approvedHomePath(user: AuthUser) {
if (user.onboardingCompletedAt == null) {
return canAccessRole(user, "admin") ? "/admin" : "/onboarding";
}
return roleHomePath(user.role);
}
function OnboardingGate({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
@ -82,6 +93,9 @@ function OnboardingGate({ children }: { children: ReactNode }) {
if (loading) return <BootScreen />;
if (path === "/pending") return <>{children}</>;
if (user && user.onboardingCompletedAt == null && path !== "/onboarding") {
if (isAdminWorkspacePath(path) && canAccessRole(user, "admin")) {
return <>{children}</>;
}
return <Navigate to="/onboarding" replace state={{ from: path }} />;
}
if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) {
@ -100,12 +114,7 @@ function PendingApprovalGate({ children }: { children: ReactNode }) {
return <Navigate to="/pending" replace state={{ from: path }} />;
}
if (user && user.accountStatus === "approved" && path === "/pending") {
return (
<Navigate
to={user.onboardingCompletedAt == null ? "/onboarding" : roleHomePath(user.role)}
replace
/>
);
return <Navigate to={approvedHomePath(user)} replace />;
}
return <>{children}</>;
}
@ -118,7 +127,7 @@ function RootRedirect() {
return <Navigate to="/pending" replace />;
}
if (user && user.onboardingCompletedAt == null) {
return <Navigate to="/onboarding" replace />;
return <Navigate to={approvedHomePath(user)} replace />;
}
return <Navigate to={user ? roleHomePath(user.role) : "/login"} replace />;
}

View file

@ -2439,6 +2439,33 @@ export interface components {
source_note: string;
terms: components["schemas"]["LegalDocument"];
};
/**
* LiveCoachCreditEvent
* @description / .
*/
LiveCoachCreditEvent: {
/** Balance */
balance: number;
/** Created At */
created_at: string;
/** Delta */
delta: number;
/** Event Id */
event_id: string;
/**
* Event Type
* @enum {string}
*/
event_type: "use" | "recharge";
/** Reason */
reason: string;
/** Session Id */
session_id: string;
/** Stage */
stage?: ("라포" | "탐색" | "개입" | "정리") | null;
/** Turn Seq */
turn_seq: number;
};
/**
* LiveCoachEvent
* @description .
@ -2462,8 +2489,11 @@ export interface components {
};
/** LiveCoachHistoryResponse */
LiveCoachHistoryResponse: {
/** Credit Events */
credit_events?: components["schemas"]["LiveCoachCreditEvent"][];
/** Events */
events?: components["schemas"]["LiveCoachEvent"][];
quota?: components["schemas"]["LiveCoachQuota"];
/**
* Source
* @default runtime
@ -2471,6 +2501,16 @@ export interface components {
*/
source: "database" | "runtime";
};
/**
* LiveCoachQuota
* @description .
*/
LiveCoachQuota: {
/** Max */
max: number;
/** Remaining */
remaining: number;
};
/** LiveCoachRequest */
LiveCoachRequest: {
/** Client Reply */
@ -2544,6 +2584,8 @@ export interface components {
* @description .
*/
LiveCoachSuggestion: {
/** Credit Events */
credit_events?: components["schemas"]["LiveCoachCreditEvent"][];
/**
* Focus
* @default exploration
@ -2559,6 +2601,13 @@ export interface components {
message: string;
/** Next Utterance */
next_utterance?: string | null;
/**
* Persistence Source
* @default database
* @enum {string}
*/
persistence_source: "database" | "runtime";
quota?: components["schemas"]["LiveCoachQuota"] | null;
/** Rationale */
rationale?: string | null;
/** Safety Note */
@ -3784,6 +3833,14 @@ export interface components {
client_turn_count: number;
/** Ended At */
ended_at?: string | null;
/** Evaluation Error */
evaluation_error?: string | null;
/**
* Evaluation Status
* @default pending
* @enum {string}
*/
evaluation_status: "pending" | "ready" | "error";
/** Learner Id */
learner_id: string;
/** Learner Label */
@ -3796,6 +3853,11 @@ export interface components {
persona_name: string;
/** Review Note */
review_note?: string | null;
/**
* Review Ready
* @default false
*/
review_ready: boolean;
/**
* Review Status
* @default pending
@ -3817,6 +3879,12 @@ export interface components {
started_at: string;
/** Status */
status: string;
/**
* Supervisor State
* @default
* @enum {string}
*/
supervisor_state: "기록 대기" | "평가 대기" | "평가 완료" | "평가 실패";
/** Turn Count */
turn_count: number;
};

View file

@ -201,8 +201,10 @@ export type SessionStartResponse = ApiSchema<"SessionStartResponse">;
/** POST /sessions/{id}/turn — sessions.py TurnResponse */
export type TurnResponse = ApiSchema<"TurnResponse">;
export type LiveCoachRequest = ApiSchema<"LiveCoachRequest">;
export type LiveCoachCreditEvent = ApiSchema<"LiveCoachCreditEvent">;
export type LiveCoachEvent = ApiSchema<"LiveCoachEvent">;
export type LiveCoachHistoryResponse = ApiSchema<"LiveCoachHistoryResponse">;
export type LiveCoachQuota = ApiSchema<"LiveCoachQuota">;
export type LiveCoachSuggestion = ApiSchema<"LiveCoachSuggestion">;
export type LiveCoachSource = ApiSchema<"LiveCoachSource">;
export type ReevaluateRequest = ApiSchema<"ReevaluateRequest">;

View file

@ -21,7 +21,19 @@ import "./professor.css";
type LoadState = "loading" | "ready" | "error";
type ProfessorView = "console" | "analysis";
type LearnerSort = "latest" | "sessions" | "score" | "delta";
type LearnerAnalysisTab = "trend" | "sessions" | "stages";
type LearnerAnalysisTab = "personas" | "trend" | "sessions" | "stages";
type PersonaSessionGroup = {
key: string;
personaCode: string;
personaName: string;
sessions: TeacherSessionSummary[];
latestAt: string | null;
endedSessions: number;
pendingReviews: number;
closedReviews: number;
avgScore: number | null;
avgRapport: number | null;
};
function formatDateTime(value: string | null): string {
if (!value) return "-";
@ -51,6 +63,24 @@ function formatRapport(value: number | null | undefined): string {
return `${Math.round(((value + 1) / 2) * 100)}%`;
}
function average(values: Array<number | null | undefined>): number | null {
const numbers = values.filter(
(value): value is number => typeof value === "number" && !Number.isNaN(value),
);
if (numbers.length === 0) return null;
return numbers.reduce((sum, value) => sum + value, 0) / numbers.length;
}
function timeValue(value: string | null | undefined): number {
if (!value) return 0;
const time = new Date(value).getTime();
return Number.isNaN(time) ? 0 : time;
}
function domToken(value: string): string {
return value.replace(/[^A-Za-z0-9_-]+/g, "-") || "unknown";
}
function trendLabel(value: string): string {
if (value === "up") return "상승";
if (value === "down") return "하락";
@ -74,6 +104,16 @@ function sessionReviewTone(status: string | null | undefined): "accent" | "neutr
return "neutral";
}
function sessionSupervisorTone(status: string | null | undefined): "accent" | "neutral" | "warn" {
if (status === "평가 완료") return "accent";
if (status === "평가 실패" || status === "평가 대기") return "warn";
return "neutral";
}
function sessionSupervisorLabel(session: TeacherSessionSummary): string {
return session.supervisor_state ?? (session.review_ready ? "평가 완료" : "기록 대기");
}
function personaReviewStatusLabel(status: PersonaReviewStatus): string {
if (status === "review") return "검수 대기";
if (status === "draft") return "수정 대기";
@ -557,6 +597,9 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
</div>
<div className="pf-session__meta">
<span>{formatDateTime(session.ended_at ?? null)}</span>
<Badge tone={sessionSupervisorTone(sessionSupervisorLabel(session))}>
{sessionSupervisorLabel(session)}
</Badge>
<Badge tone={sessionReviewTone(session.review_status)}>
{sessionReviewStatusLabel(session.review_status)}
</Badge>
@ -773,6 +816,7 @@ export default function Professor({ view = "console" }: { view?: ProfessorView }
</Badge>
{session.status === "ended" ? (
<small className="pf-recent__review-state">
{sessionSupervisorLabel(session)} ·{" "}
{sessionReviewStatusLabel(session.review_status)}
</small>
) : null}
@ -1033,13 +1077,80 @@ function LearnerAnalysisPanel({
analysis: TeacherLearnerAnalysisResponse;
onOpenSession: (sessionId: string) => void;
}) {
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("trend");
const [activeTab, setActiveTab] = useState<LearnerAnalysisTab>("personas");
const [expandedPersonaKey, setExpandedPersonaKey] = useState<string | null>(null);
const points = analysis.points ?? [];
const sessions = analysis.sessions ?? [];
const pointBySession = new Map(points.map((point) => [point.session_id, point]));
const pointBySession = useMemo(
() => new Map(points.map((point) => [point.session_id, point])),
[points],
);
const stageBreakdown = analysis.stage_breakdown ?? [];
const summary = analysis.summary;
const personaGroups = useMemo<PersonaSessionGroup[]>(() => {
const groups = new Map<string, PersonaSessionGroup>();
sessions.forEach((session) => {
const personaCode = session.persona_code || "페르소나";
const personaName = session.persona_name || personaCode;
const key = personaCode || personaName;
const group =
groups.get(key) ??
{
key,
personaCode,
personaName,
sessions: [],
latestAt: null,
endedSessions: 0,
pendingReviews: 0,
closedReviews: 0,
avgScore: null,
avgRapport: null,
};
group.sessions.push(session);
if (session.status === "ended") group.endedSessions += 1;
if (session.review_status === "closed") {
group.closedReviews += 1;
} else {
group.pendingReviews += 1;
}
const sessionTime = timeValue(session.ended_at ?? session.started_at);
if (sessionTime >= timeValue(group.latestAt)) {
group.latestAt = session.ended_at ?? session.started_at;
}
groups.set(key, group);
});
return Array.from(groups.values())
.map((group) => {
const orderedSessions = [...group.sessions].sort(
(left, right) =>
left.session_no - right.session_no ||
timeValue(left.started_at) - timeValue(right.started_at),
);
return {
...group,
sessions: orderedSessions,
avgScore: average(
orderedSessions.map((session) => pointBySession.get(session.session_id)?.score),
),
avgRapport: average(
orderedSessions.map((session) => pointBySession.get(session.session_id)?.rapport),
),
};
})
.sort(
(left, right) =>
timeValue(right.latestAt) - timeValue(left.latestAt) ||
right.sessions.length - left.sessions.length,
);
}, [pointBySession, sessions]);
useEffect(() => {
setActiveTab("personas");
setExpandedPersonaKey(null);
}, [analysis.learner_id]);
const tabs: Array<{ key: LearnerAnalysisTab; label: string; count: string }> = [
{ key: "personas", label: "페르소나별 회기", count: `${personaGroups.length}` },
{ key: "trend", label: "추이", count: `${points.length}` },
{ key: "sessions", label: "전체 회기", count: `${sessions.length}` },
{ key: "stages", label: "단계 분석", count: `${stageBreakdown.length}단계` },
@ -1098,6 +1209,94 @@ function LearnerAnalysisPanel({
))}
</div>
{activeTab === "personas" ? (
<section className="pf-analysis__personas" aria-label="페르소나별 회기">
<div className="pf-analysis__subhead">
<b> </b>
<span>
{personaGroups.length} · {sessions.length}
</span>
</div>
{personaGroups.length > 0 ? (
<div className="pf-persona-drill">
{personaGroups.map((group) => {
const isExpanded = expandedPersonaKey === group.key;
const panelId = `persona-sessions-${domToken(group.key)}`;
return (
<article
className={`pf-persona-group ${isExpanded ? "is-open" : ""}`}
data-learner-persona-group="true"
key={group.key}
>
<button
type="button"
className="pf-persona-group__summary"
aria-expanded={isExpanded}
aria-controls={panelId}
aria-label={`${group.personaCode} ${group.personaName} 회기 ${
isExpanded ? "접기" : "펼치기"
}`}
onClick={() => setExpandedPersonaKey(isExpanded ? null : group.key)}
>
<span className="pf-persona-group__code">{group.personaCode}</span>
<span className="pf-persona-group__main">
<b>{group.personaName}</b>
<small>
{formatDateTime(group.latestAt)} · {group.endedSessions}/
{group.sessions.length} · {group.pendingReviews}
</small>
</span>
<span className="pf-persona-group__metrics">
<span>
<small></small>
<b>{group.sessions.length}</b>
</span>
<span>
<small></small>
<b>{formatScore(group.avgScore)}</b>
</span>
<span>
<small></small>
<b>{formatRapport(group.avgRapport)}</b>
</span>
<span>
<small></small>
<b>
{group.closedReviews}/{group.sessions.length}
</b>
</span>
</span>
<span className="pf-persona-group__open">
<Icon name={isExpanded ? "arrow-up" : "arrow-down"} size={13} />
{isExpanded ? "회기 접기" : "회기 펼치기"}
</span>
</button>
{isExpanded ? (
<div
className="pf-persona-sessions"
id={panelId}
data-learner-persona-sessions="true"
>
{group.sessions.map((session) => (
<LearnerSessionRow
session={session}
point={pointBySession.get(session.session_id)}
key={session.session_id}
onOpen={() => onOpenSession(session.session_id)}
/>
))}
</div>
) : null}
</article>
);
})}
</div>
) : (
<EmptyState title="페르소나 기록 없음" desc="이 학습자의 페르소나별 회기 기록이 없습니다." />
)}
</section>
) : null}
{activeTab === "trend" ? (
<section className="pf-analysis__chart" aria-label="회기별 추이">
<div className="pf-analysis__subhead">
@ -1197,6 +1396,12 @@ function LearnerSessionRow({
<small></small>
<b>{formatRapport(point?.rapport)}</b>
</span>
<span>
<small>AI </small>
<Badge tone={sessionSupervisorTone(sessionSupervisorLabel(session))}>
{sessionSupervisorLabel(session)}
</Badge>
</span>
<span>
<small></small>
<Badge tone={sessionReviewTone(session.review_status)}>

View file

@ -31,7 +31,10 @@ import {
personaApi,
sessionApi,
type CrisisResource,
type LiveCoachCreditEvent,
type LiveCoachEvent,
type LiveCoachHistoryResponse,
type LiveCoachQuota,
type LiveCoachSuggestion,
type PersonaSummary,
type SessionDetailResponse,
@ -70,6 +73,7 @@ type VoiceServerState = "idle" | "listening" | "thinking" | "speaking";
interface VoiceEvent {
type?: string;
code?: string;
state?: VoiceServerState;
text?: string;
final?: boolean;
@ -83,6 +87,22 @@ interface VoiceEvent {
conversation_stopped?: boolean;
}
const VOICE_TURN_SAVE_FAILED =
"음성 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
const VOICE_CONNECTION_SAVE_FAILED =
"음성 연결이 종료되어 발화를 저장하지 못했습니다. 방금 말한 내용을 확인한 뒤 다시 시도해 주세요.";
function userFacingVoiceError(payload: VoiceEvent): string {
const detail = payload.detail ?? "";
if (
payload.code === "turn_persistence_unavailable" ||
(detail.includes("turn append") && detail.includes("persistence unavailable"))
) {
return VOICE_TURN_SAVE_FAILED;
}
return detail || "음성 처리 중 오류가 발생했습니다.";
}
type AudioContextWindow = Window & { webkitAudioContext?: typeof AudioContext };
type VoiceCaptureMode = "audio-worklet" | "media-recorder";
@ -137,6 +157,8 @@ interface Utterance {
at: number;
/** 진행 중(스트리밍/타이핑) 발화 여부 */
partial?: boolean;
/** 음성 WebSocket이 reply 전에 실패해 DB 턴으로 확정되지 않은 발화 */
failed?: boolean;
}
interface PhaseInfo {
@ -164,6 +186,7 @@ const THEORY_MODE_OPTIONS: { value: TheoryMode; label: string; detail: string }[
{ value: "cbt", label: "CBT", detail: "생각·행동" },
{ value: "integrative", label: "통합", detail: "혼합 접근" },
];
const DEFAULT_COACH_QUOTA: LiveCoachQuota = { remaining: 3, max: 3 };
const VOICE_WORKLET_MODULE_URL = "/worklets/voice-capture-worklet.js";
const VOICE_WORKLET_PROCESSOR = "voice-capture-processor";
@ -839,8 +862,15 @@ export default function Session() {
const [coachError, setCoachError] = useState<string | null>(null);
const [coachEvidenceOpen, setCoachEvidenceOpen] = useState(false);
const [coachHistory, setCoachHistory] = useState<LiveCoachEvent[]>([]);
const [coachQuota, setCoachQuota] = useState<LiveCoachQuota>(DEFAULT_COACH_QUOTA);
const [coachCreditEvents, setCoachCreditEvents] = useState<LiveCoachCreditEvent[]>([]);
const [coachCreditPulse, setCoachCreditPulse] = useState<LiveCoachCreditEvent | null>(null);
const [coachHistoryOpen, setCoachHistoryOpen] = useState(false);
const [coachHistoryTurnSeq, setCoachHistoryTurnSeq] = useState<number | null>(null);
const [coachHistoryLoading, setCoachHistoryLoading] = useState(false);
const [coachHistoryError, setCoachHistoryError] = useState<string | null>(null);
const [coachPersistenceSource, setCoachPersistenceSource] = useState<"database" | "runtime" | null>(null);
const [coachSyncWarning, setCoachSyncWarning] = useState<string | null>(null);
// ── 경과 타이머 ──
const [elapsed, setElapsed] = useState(0);
@ -860,6 +890,23 @@ export default function Session() {
const pendingVoiceLearnerTextRef = useRef<string>("");
const coachEvidenceCloseRef = useRef<HTMLButtonElement>(null);
const coachHistoryCloseRef = useRef<HTMLButtonElement>(null);
const coachCreditSeenRef = useRef<Set<string>>(new Set());
const coachCreditPulseTimerRef = useRef<number | null>(null);
const failPendingVoiceLearnerTurn = useCallback(() => {
const pendingId = pendingVoiceLearnerIdRef.current;
pendingVoiceLearnerIdRef.current = null;
pendingVoiceLearnerTextRef.current = "";
if (pendingId == null) return false;
setUtterances((prev) =>
prev.map((utterance) =>
utterance.id === pendingId
? { ...utterance, partial: false, failed: true, turnSeq: undefined }
: utterance,
),
);
return true;
}, []);
const ensureVoiceAudioContext = useCallback(() => {
const existing = audioContextRef.current;
@ -911,6 +958,12 @@ export default function Session() {
setCoachError(null);
setCoachEvidenceOpen(false);
setCoachHistory([]);
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachCreditPulse(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
coachCreditSeenRef.current = new Set();
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
}, [navigate, routeIsSessionId, routeParam]);
@ -1001,6 +1054,48 @@ export default function Session() {
setSignalSeq((seq) => [...seq.slice(-4), tone]);
}, []);
const showCoachCreditPulse = useCallback(
(event: LiveCoachCreditEvent) => {
setCoachCreditPulse(event);
if (coachCreditPulseTimerRef.current) {
window.clearTimeout(coachCreditPulseTimerRef.current);
}
coachCreditPulseTimerRef.current = window.setTimeout(() => {
setCoachCreditPulse(null);
coachCreditPulseTimerRef.current = null;
}, 2600);
pushSignal(
event.event_type === "recharge" ? "pos" : "neutral",
event.event_type === "recharge" ? "코칭 기회 충전" : "코칭 기회 사용",
);
},
[pushSignal],
);
const applyCoachCreditEvents = useCallback(
(events: LiveCoachCreditEvent[], animate: boolean) => {
setCoachCreditEvents(events);
const fresh: LiveCoachCreditEvent[] = [];
for (const event of events) {
if (coachCreditSeenRef.current.has(event.event_id)) continue;
coachCreditSeenRef.current.add(event.event_id);
fresh.push(event);
}
if (animate && fresh.length > 0) {
showCoachCreditPulse(fresh[fresh.length - 1]);
}
},
[showCoachCreditPulse],
);
useEffect(() => {
return () => {
if (coachCreditPulseTimerRef.current) {
window.clearTimeout(coachCreditPulseTimerRef.current);
}
};
}, []);
const applyCrisisGate = useCallback((resource?: CrisisResource | null) => {
const fallback: CrisisResource = {
title: "자살예방상담전화 109",
@ -1019,29 +1114,61 @@ export default function Session() {
}, [pushSignal]);
const refreshCoachHistory = useCallback(
async (sessionId = liveSessionId) => {
async (
sessionId = liveSessionId,
options: { animateCredits?: boolean; surfaceErrors?: boolean; warnOnFailure?: boolean } = {},
): Promise<LiveCoachHistoryResponse | null> => {
if (options.surfaceErrors) {
setCoachHistoryLoading(true);
setCoachHistoryError(null);
}
if (!sessionId) {
setCoachHistory([]);
return;
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachHistoryError(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
if (options.surfaceErrors) setCoachHistoryLoading(false);
return null;
}
try {
const history = await sessionApi.liveCoachHistory(sessionId);
setCoachHistory(history.events ?? []);
setCoachQuota(history.quota ?? DEFAULT_COACH_QUOTA);
setCoachPersistenceSource(history.source);
setCoachSyncWarning(
history.source === "runtime"
? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다."
: null,
);
setCoachHistoryError(null);
applyCoachCreditEvents(history.credit_events ?? [], !!options.animateCredits);
return history;
} catch {
/* 코칭 이력 조회 실패는 회기 진행을 막지 않는다. */
if (options.surfaceErrors) {
setCoachHistoryError("코칭 이력을 불러오지 못했습니다. 잠시 뒤 다시 열어 주세요.");
}
if (options.warnOnFailure) {
setCoachSyncWarning("코칭은 표시됐지만 이력 동기화를 확인하지 못했습니다. 이력 보기에서 다시 확인해 주세요.");
}
return null;
} finally {
if (options.surfaceErrors) setCoachHistoryLoading(false);
}
},
[liveSessionId],
[applyCoachCreditEvents, liveSessionId],
);
const openCoachHistory = useCallback(
(turnSeq: number | null = null) => {
setCoachHistoryTurnSeq(turnSeq);
setCoachHistoryError(null);
setCoachEvidenceOpen(false);
setCoachHistoryOpen(true);
void refreshCoachHistory();
void refreshCoachHistory(liveSessionId, { surfaceErrors: true });
},
[refreshCoachHistory],
[liveSessionId, refreshCoachHistory],
);
const requestLiveCoach = useCallback(
@ -1055,6 +1182,25 @@ export default function Session() {
turnSeq?: number;
}) => {
if (!liveSessionId || feedbackMode !== "coached") return;
let availableCoachCredits = coachQuota.remaining ?? 0;
if (availableCoachCredits <= 0) {
setCoachError(null);
const refreshed = await refreshCoachHistory(liveSessionId, {
animateCredits: true,
warnOnFailure: true,
});
if (!refreshed) {
setCoachError("코칭 기회를 확인하지 못했습니다. 잠시 뒤 다시 시도해 주세요.");
pushSignal("warn", "코칭 기회 확인 실패");
return;
}
availableCoachCredits = refreshed.quota?.remaining ?? 0;
}
if (availableCoachCredits <= 0) {
setCoachError("코칭 기회를 모두 사용했습니다. 좋은 발화로 내담자의 변화 신호가 생기면 1개씩 다시 충전됩니다.");
pushSignal("warn", "코칭 기회 소진");
return;
}
setCoachLoading(true);
setCoachError(null);
try {
@ -1064,16 +1210,41 @@ export default function Session() {
turn_seq: turnSeq,
});
setCoachSuggestion(suggestion);
setCoachPersistenceSource(suggestion.persistence_source ?? null);
setCoachSyncWarning(
suggestion.persistence_source === "runtime"
? "코칭 이력이 DB에 확정 저장되지 않아 임시 이력으로 표시됩니다."
: null,
);
if (suggestion.quota) {
setCoachQuota(suggestion.quota);
}
if (suggestion.credit_events?.length) {
applyCoachCreditEvents(suggestion.credit_events, true);
}
pushSignal(suggestion.tone, suggestion.title);
void refreshCoachHistory();
} catch {
setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다.");
pushSignal("warn", "코칭 연결 실패");
void refreshCoachHistory(liveSessionId, { animateCredits: false, warnOnFailure: true });
} catch (err) {
if (err instanceof ApiError && err.status === 409) {
setCoachQuota((current) => ({ ...current, remaining: 0 }));
setCoachError("코칭 기회를 모두 사용했습니다. 좋은 발화가 실제 변화로 이어지면 다시 충전됩니다.");
pushSignal("warn", "코칭 기회 소진");
} else {
setCoachError("코칭 근거를 불러오지 못했습니다. 다음 턴에서 다시 시도합니다.");
pushSignal("warn", "코칭 연결 실패");
}
} finally {
setCoachLoading(false);
}
},
[feedbackMode, liveSessionId, pushSignal, refreshCoachHistory],
[
applyCoachCreditEvents,
coachQuota.remaining,
feedbackMode,
liveSessionId,
pushSignal,
refreshCoachHistory,
],
);
useEffect(() => {
@ -1125,6 +1296,13 @@ export default function Session() {
setCoachSuggestion(null);
setCoachError(null);
setCoachEvidenceOpen(false);
setCoachHistory([]);
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachCreditPulse(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
coachCreditSeenRef.current = new Set();
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
setClientReplyPending(false);
@ -1171,6 +1349,12 @@ export default function Session() {
setCoachError(null);
setCoachEvidenceOpen(false);
setCoachHistory([]);
setCoachQuota(DEFAULT_COACH_QUOTA);
setCoachCreditEvents([]);
setCoachCreditPulse(null);
setCoachPersistenceSource(null);
setCoachSyncWarning(null);
coachCreditSeenRef.current = new Set();
setCoachHistoryOpen(false);
setCoachHistoryTurnSeq(null);
if (res.degraded) {
@ -1684,6 +1868,7 @@ export default function Session() {
}
if (payload.type === "transcript" && payload.final && payload.text) {
failPendingVoiceLearnerTurn();
const id = nextId();
pendingVoiceLearnerIdRef.current = id;
pendingVoiceLearnerTextRef.current = payload.text;
@ -1712,7 +1897,9 @@ export default function Session() {
if (pendingId != null) {
setUtterances((prev) =>
prev.map((u) =>
u.id === pendingId ? { ...u, partial: false, turnSeq: payload.turn_seq } : u,
u.id === pendingId
? { ...u, partial: false, failed: false, turnSeq: payload.turn_seq }
: u,
),
);
pendingVoiceLearnerIdRef.current = null;
@ -1729,6 +1916,9 @@ export default function Session() {
turnSeq: payload.turn_seq,
});
}
if (conversationStopped) {
closeVoiceSocket();
}
return;
}
@ -1739,9 +1929,11 @@ export default function Session() {
if (payload.type === "degraded") {
setClientReplyPending(false);
const hadPendingLearner = failPendingVoiceLearnerTurn();
const fallbackReason = "음성 설정이 완료되지 않아 지금은 텍스트 입력으로 진행합니다.";
const reason =
payload.reason && !payload.reason.includes("OPENAI") ? payload.reason : fallbackReason;
if (hadPendingLearner) setTurnError(fallbackReason);
shutdownVoice("degraded", reason);
pushSignal("warn", "음성 기능 미설정");
return;
@ -1749,24 +1941,24 @@ export default function Session() {
if (payload.type === "error") {
setClientReplyPending(false);
if (pendingVoiceLearnerIdRef.current != null && payload.detail?.includes("engine unavailable")) {
const pendingId = pendingVoiceLearnerIdRef.current;
setUtterances((prev) => prev.filter((u) => u.id !== pendingId));
pendingVoiceLearnerIdRef.current = null;
pendingVoiceLearnerTextRef.current = "";
}
shutdownVoice("error", payload.detail || "음성 처리 중 오류가 발생했습니다.");
failPendingVoiceLearnerTurn();
const detail = userFacingVoiceError(payload);
setTurnError(detail);
shutdownVoice("error", detail);
pushSignal("warn", "음성 오류");
}
};
ws.onerror = () => {
setClientReplyPending(false);
shutdownVoice("error", "음성 연결을 확인하지 못했습니다.");
failPendingVoiceLearnerTurn();
setTurnError(VOICE_CONNECTION_SAVE_FAILED);
shutdownVoice("error", VOICE_CONNECTION_SAVE_FAILED);
pushSignal("warn", "음성 연결 실패");
};
ws.onclose = () => {
const hadPendingLearner = failPendingVoiceLearnerTurn();
const capture = voiceCaptureRef.current;
voiceCaptureRef.current = null;
capture?.abort();
@ -1776,6 +1968,13 @@ export default function Session() {
}
stopMicStream();
setMicOn(false);
setClientReplyPending(false);
if (hadPendingLearner) {
setTurnError(VOICE_CONNECTION_SAVE_FAILED);
setVoiceStatus("error");
setVoiceDetail(VOICE_CONNECTION_SAVE_FAILED);
return;
}
setVoiceStatus((current) => (current === "degraded" || current === "error" ? current : "idle"));
setVoiceDetail((current) => {
if (
@ -1794,6 +1993,7 @@ export default function Session() {
closeVoiceSocket,
elapsed,
ensureVoiceAudioContext,
failPendingVoiceLearnerTurn,
liveSessionId,
paused,
sessionEnded,
@ -2019,7 +2219,7 @@ export default function Session() {
const elapsedLabel = formatElapsed(elapsed);
const recommendedSessionSeconds = 30 * 60;
const remainingLabel = formatElapsed(Math.max(0, recommendedSessionSeconds - elapsed));
const turnCount = utterances.filter((utterance) => !utterance.partial).length;
const turnCount = utterances.filter((utterance) => !utterance.partial && !utterance.failed).length;
const latestClientUtterance = [...utterances]
.reverse()
.find((utterance) => utterance.speaker === "client" && utterance.text.trim());
@ -2051,10 +2251,25 @@ export default function Session() {
: null;
const coachSources = coachSuggestion?.sources ?? [];
const coachTone = coachSuggestion?.tone ?? "neutral";
const coachQuotaMax = Math.max(1, coachQuota.max ?? DEFAULT_COACH_QUOTA.max);
const coachQuotaRemaining = Math.max(
0,
Math.min(coachQuotaMax, coachQuota.remaining ?? DEFAULT_COACH_QUOTA.remaining),
);
const coachQuotaSlots = Array.from({ length: coachQuotaMax }, (_, index) => index);
const coachCreditPulseText =
coachCreditPulse == null
? null
: coachCreditPulse.event_type === "recharge"
? `+1 충전 · ${coachCreditPulse.balance}/${coachQuotaMax}`
: `-1 사용 · ${coachCreditPulse.balance}/${coachQuotaMax}`;
const coachIsDegraded = coachSuggestion?.status === "degraded";
const coachDegradedNote =
"AI 코칭 엔진 응답 대신 워크북 규칙과 현재 턴 신호로 만든 대체 제안입니다.";
const coachStatusText = coachLoading
? "코치가 근거를 확인 중"
: coachSuggestion?.status === "degraded"
? "규칙 기반 코칭"
: coachIsDegraded
? "AI 응답 대체"
: coachSuggestion
? "근거 확인 완료"
: "턴 완료 후 개입";
@ -2525,7 +2740,8 @@ export default function Session() {
className={
"sx-utt " +
(u.speaker === "client" ? "is-client" : "is-learner") +
(u.partial ? " is-partial" : "")
(u.partial ? " is-partial" : "") +
(u.failed ? " is-failed" : "")
}
>
<div className="sx-utt__head">
@ -2539,6 +2755,11 @@ export default function Session() {
{u.text}
{u.partial ? <span className="sx-utt__caret" /> : null}
</div>
{u.failed ? (
<span className="sx-utt__status" role="note">
· .
</span>
) : null}
{turnCoachEvents.length && u.turnSeq ? (
<button
type="button"
@ -2548,9 +2769,14 @@ export default function Session() {
? " is-pos"
: latestCoach?.suggestion.tone === "warn"
? " is-warn"
: "")
: "") +
(latestCoach?.suggestion.status === "degraded" ? " is-degraded" : "")
}
title={
latestCoach?.suggestion.status === "degraded"
? `AI 응답 대체: ${latestCoach.suggestion.title}`
: latestCoach?.suggestion.title ?? "코칭 이력"
}
title={latestCoach?.suggestion.title ?? "코칭 이력"}
onClick={() => openCoachHistory(u.turnSeq ?? null)}
>
C
@ -2739,7 +2965,8 @@ export default function Session() {
className={
"sx-coach-card" +
(coachTone === "pos" ? " is-pos" : coachTone === "warn" ? " is-warn" : "") +
(coachLoading ? " is-loading" : "")
(coachLoading ? " is-loading" : "") +
(coachIsDegraded ? " is-degraded" : "")
}
>
<div className="sx-coach-avatar" aria-hidden="true">
@ -2751,11 +2978,52 @@ export default function Session() {
</div>
<div className="sx-coach-bubble">
<div className="sx-coach-bubble__meta">
<b>AI </b>
<b>{coachIsDegraded ? "대체 코칭" : "AI 코치"}</b>
<span>{coachStatusText}</span>
</div>
{coachIsDegraded ? (
<p className="sx-coach-degraded-note">{coachDegradedNote}</p>
) : null}
{coachSyncWarning ? (
<p
className={
"sx-coach-persistence-note" +
(coachPersistenceSource === "runtime" ? " is-runtime" : "")
}
role="status"
>
{coachSyncWarning}
</p>
) : null}
<div className="sx-coach-quota" aria-live="polite">
<span> </span>
<span className="sx-coach-quota__dots" aria-hidden="true">
{coachQuotaSlots.map((slot) => (
<i
key={slot}
className={slot < coachQuotaRemaining ? "is-filled" : ""}
/>
))}
</span>
<b>
{coachQuotaRemaining}/{coachQuotaMax}
</b>
</div>
{coachCreditPulseText ? (
<div
className={
"sx-coach-credit-pulse" +
(coachCreditPulse?.event_type === "recharge" ? " is-recharge" : "")
}
aria-live="polite"
>
{coachCreditPulseText}
</div>
) : null}
{coachLoading ? (
<p> .</p>
) : coachError ? (
<p>{coachError}</p>
) : coachSuggestion ? (
<>
<strong>{coachSuggestion.title}</strong>
@ -2772,8 +3040,8 @@ export default function Session() {
</button>
</div>
</>
) : coachError ? (
<p>{coachError}</p>
) : coachQuotaRemaining <= 0 ? (
<p> . 1 .</p>
) : (
<p> .</p>
)}
@ -2928,8 +3196,17 @@ export default function Session() {
<button
className={feedbackMode === "coached" ? "is-on" : ""}
onClick={() => setFeedbackMode("coached")}
aria-label={`코칭 모드, 남은 기회 ${coachQuotaRemaining}`}
>
<span></span>
<span
className={
"sx-segmented__badge" + (coachQuotaRemaining <= 0 ? " is-empty" : "")
}
aria-hidden="true"
>
{coachQuotaRemaining}
</span>
</button>
</div>
</div>
@ -2992,6 +3269,24 @@ export default function Session() {
<p id="sx-coach-history-desc">
.
</p>
{coachPersistenceSource === "runtime" ? (
<div className="sx-coach-history__source-alert" role="status">
.
</div>
) : null}
{coachCreditEvents.length > 0 ? (
<div className="sx-coach-credit-log" aria-label="코칭 기회 기록">
{coachCreditEvents.slice(-4).map((event) => (
<span
key={event.event_id}
className={event.event_type === "recharge" ? "is-recharge" : ""}
>
{event.event_type === "recharge" ? "+1 충전" : "-1 사용"} ·{" "}
{event.balance}/{coachQuotaMax}
</span>
))}
</div>
) : null}
</div>
<button
ref={coachHistoryCloseRef}
@ -3004,7 +3299,15 @@ export default function Session() {
</div>
<div className="sx-coach-history__body">
{activeCoachEvents.length ? (
{coachHistoryLoading ? (
<div className="sx-coach-history__empty">
.
</div>
) : coachHistoryError ? (
<div className="sx-coach-history__empty is-error" role="alert">
{coachHistoryError}
</div>
) : activeCoachEvents.length ? (
activeCoachEvents.map((event) => (
<article
key={event.event_id}
@ -3014,11 +3317,15 @@ export default function Session() {
? " is-pos"
: event.suggestion.tone === "warn"
? " is-warn"
: "")
: "") +
(event.suggestion.status === "degraded" ? " is-degraded" : "")
}
>
<div className="sx-coach-history__item-top">
<span>{event.turn_seq} · {event.stage ?? "단계 미상"}</span>
{event.suggestion.status === "degraded" ? (
<b className="sx-coach-degraded-badge">AI </b>
) : null}
<time dateTime={event.created_at}>
{formatCoachTimestamp(event.created_at)}
</time>
@ -3088,7 +3395,11 @@ export default function Session() {
</span>
<div>
<h2 id="sx-coach-modal-title">{coachSuggestion.title}</h2>
<p id="sx-coach-modal-desc"> .</p>
<p id="sx-coach-modal-desc">
{coachIsDegraded
? coachDegradedNote
: "방금 코칭 판단에 사용한 근거와 다음 발화 제안입니다."}
</p>
</div>
</div>

View file

@ -42,7 +42,7 @@ 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 REVIEW_READY_POLL_LIMIT = 60;
const PREPOST_MEASURES: Array<{ key: PrepostMeasureName; label: string; hint: string }> = [
{ key: "self_efficacy", label: "자기효능감", hint: "상담 수행 자신감" },
@ -540,26 +540,32 @@ function PrepostMeasureCard() {
};
}, []);
const dirtyKeys = useMemo(
const changedKeys = useMemo(
() =>
Object.keys(draft).filter((key) => {
const split = splitPrepostDraftKey(key);
if (!split) return false;
const value = draft[key]?.trim() ?? "";
return value !== (baseline[key] ?? "") && parsePrepostScore(value) !== null;
return value !== (baseline[key] ?? "");
}),
[baseline, draft],
);
const dirtyKeys = useMemo(
() => changedKeys.filter((key) => parsePrepostScore(draft[key]?.trim() ?? "") !== null),
[changedKeys, draft],
);
const invalidKeys = useMemo(
() =>
Object.keys(draft).filter((key) => {
const split = splitPrepostDraftKey(key);
if (!split) return false;
const value = draft[key]?.trim() ?? "";
return value.length > 0 && parsePrepostScore(value) === null;
const hadSavedValue = (baseline[key] ?? "").trim().length > 0;
return (value.length === 0 && hadSavedValue) || (value.length > 0 && parsePrepostScore(value) === null);
}),
[draft],
[baseline, draft],
);
const saveDraft = async () => {
@ -657,7 +663,7 @@ function PrepostMeasureCard() {
</div>
{invalidKeys.length > 0 ? (
<p className="sr-prepost__error" role="alert">
1 5 .
1 5 . .
</p>
) : error ? (
<p className="sr-prepost__error" role="alert">
@ -668,9 +674,11 @@ function PrepostMeasureCard() {
<span>
{loading
? "불러오는 중"
: dirtyKeys.length > 0
? `${dirtyKeys.length}개 변경`
: "저장된 값 기준"}
: invalidKeys.length > 0
? "저장되지 않은 입력 있음"
: dirtyKeys.length > 0
? `${dirtyKeys.length}개 변경`
: "저장된 값 기준"}
</span>
<Button
variant="secondary"
@ -973,9 +981,14 @@ export default function SessionReview() {
const worksheetReviewStatus = (teacherReview?.worksheetStatus ?? "pending") as WorksheetReviewStatus;
const worksheetReviewLabel = worksheetReviewStatusLabel(worksheetReviewStatus);
const hasLearnerWorksheet = data.caseWorksheet?.status === "saved_by_learner";
const canCloseTeacherReview = isSupervisorView && data.sessionSignal === "종료됨";
const canDecideWorksheet = canCloseTeacherReview && hasLearnerWorksheet;
const canRetryEvaluation = canCloseTeacherReview && hasTranscript && !data.reviewReady;
const canReviewEndedSession = isSupervisorView && data.sessionSignal === "종료됨";
const canCloseTeacherReview = canReviewEndedSession && data.reviewReady;
const canDecideWorksheet = canReviewEndedSession && hasLearnerWorksheet;
const canRetryEvaluation =
canReviewEndedSession &&
hasTranscript &&
!data.reviewReady &&
data.supervisorState === "평가 실패";
const reviewReadiness = hasTranscript
? `${turns.length}개 발화 기반`
: "축어록 저장 후 생성";
@ -1282,7 +1295,7 @@ export default function SessionReview() {
<p className="sr-teacher-review__meta">
{teacherReview.reviewedAt}
</p>
) : canCloseTeacherReview ? (
) : canReviewEndedSession ? (
<p className="sr-teacher-review__meta">
.
</p>

View file

@ -820,6 +820,7 @@
}
.pf-analysis__chart,
.pf-analysis__stages,
.pf-analysis__personas,
.pf-analysis__timeline{
min-width:0;
border:1px solid var(--paper-2);
@ -877,6 +878,135 @@
font-size:var(--fs-xs);
font-weight:700;
}
.pf-persona-drill{
display:flex;
flex-direction:column;
max-height:min(580px,62vh);
overflow:auto;
scrollbar-gutter:stable;
}
.pf-persona-group{
min-width:0;
border-top:1px solid var(--paper-2);
}
.pf-persona-group:first-child{
border-top:0;
}
.pf-persona-group__summary{
width:100%;
min-width:0;
display:grid;
grid-template-columns:44px minmax(160px,1fr) minmax(260px,.92fr) auto;
gap:10px;
align-items:center;
padding:12px;
border:0;
background:transparent;
color:inherit;
font:inherit;
text-align:left;
cursor:pointer;
}
.pf-persona-group__summary:hover,
.pf-persona-group.is-open .pf-persona-group__summary{
background:var(--bg-surface-2);
}
.pf-persona-group__summary:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-persona-group__code{
width:38px;
height:34px;
display:grid;
place-items:center;
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-family:var(--font-num);
font-size:12px;
font-weight:830;
}
.pf-persona-group__main{
min-width:0;
display:grid;
gap:4px;
}
.pf-persona-group__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona-group__main small{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona-group__metrics{
min-width:0;
display:grid;
grid-template-columns:repeat(4,minmax(58px,1fr));
gap:7px;
}
.pf-persona-group__metrics span{
min-width:0;
display:grid;
gap:3px;
}
.pf-persona-group__metrics small{
color:var(--text-muted);
font-size:11px;
line-height:1.2;
}
.pf-persona-group__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-persona-group__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-persona-group__summary:hover .pf-persona-group__open{
border-color:var(--accent);
}
.pf-persona-sessions{
margin:0 12px 12px 56px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
overflow:hidden;
background:var(--bg-surface);
}
.pf-persona-sessions .pf-timeline-row{
grid-template-columns:34px minmax(150px,1fr) minmax(200px,.82fr) minmax(104px,.42fr) auto;
padding:10px;
}
.pf-persona-sessions .pf-timeline-row__step{
width:28px;
height:28px;
}
.pf-timeline{
max-height:min(520px,58vh);
overflow:auto;
@ -1378,6 +1508,16 @@
.pf-analysis__metrics span:nth-child(n+4){
border-top:1px solid var(--hair);
}
.pf-persona-group__summary{
grid-template-columns:44px minmax(0,1fr) auto;
}
.pf-persona-group__metrics{
grid-column:2 / 4;
grid-row:2;
}
.pf-persona-sessions .pf-timeline-row{
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
}
.pf-timeline-row{
grid-template-columns:34px minmax(0,1fr) minmax(190px,.85fr) auto;
}
@ -1461,6 +1601,31 @@
.pf-stage-item:nth-child(n+3){
border-top:1px solid var(--hair);
}
.pf-persona-drill{
max-height:560px;
}
.pf-persona-group__summary{
grid-template-columns:38px minmax(0,1fr);
align-items:start;
}
.pf-persona-group__metrics,
.pf-persona-group__open{
grid-column:2;
}
.pf-persona-group__metrics{
grid-row:auto;
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-persona-group__open{
justify-self:start;
}
.pf-persona-sessions{
margin:0 10px 10px 48px;
}
.pf-persona-sessions .pf-timeline-row{
grid-template-columns:34px minmax(0,1fr);
align-items:start;
}
.pf-timeline{
max-height:560px;
}
@ -1607,6 +1772,28 @@
.pf-stage-item:nth-child(n+2){
border-top:1px solid var(--hair);
}
.pf-persona-group__summary{
grid-template-columns:1fr;
}
.pf-persona-group__code{
width:auto;
min-width:38px;
justify-self:start;
padding:0 10px;
}
.pf-persona-group__metrics,
.pf-persona-group__open{
grid-column:1;
}
.pf-persona-group__metrics{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-persona-sessions{
margin:0 8px 8px;
}
.pf-persona-sessions .pf-timeline-row__metrics{
grid-template-columns:1fr;
}
.pf-timeline-row__metrics{
grid-template-columns:1fr;
}

View file

@ -1030,6 +1030,11 @@
border-color: color-mix(in srgb, var(--warn-solid) 48%, transparent);
color: var(--warn-text);
}
.sx-utt__coach-mark.is-degraded {
border-color: color-mix(in srgb, var(--clay-deep) 55%, transparent);
background: color-mix(in srgb, var(--clay-tint) 68%, var(--paper));
color: var(--clay-deep);
}
.sx-utt__coach-mark:hover {
transform: translateY(-1px);
box-shadow: 0 8px 18px rgba(35, 42, 48, 0.16);
@ -1052,6 +1057,25 @@
.sx-utt.is-partial .sx-utt__line {
color: var(--text-muted);
}
.sx-utt.is-failed .sx-utt__line {
background: color-mix(in srgb, var(--warn-tint) 74%, var(--paper));
border: 1px dashed color-mix(in srgb, var(--warn-solid) 46%, transparent);
color: var(--text-strong);
}
.sx-utt__status {
align-self: center;
display: inline-flex;
flex: 0 0 auto;
white-space: nowrap;
border-radius: 999px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
background: color-mix(in srgb, var(--warn-tint) 72%, var(--paper));
color: var(--warn-text);
font-size: 11px;
font-weight: 700;
line-height: 1;
padding: 4px 7px;
}
.sx-utt.is-thinking .sx-utt__line {
display: inline-flex;
align-items: center;
@ -1427,6 +1451,19 @@
.sx-coach-card.is-warn .sx-coach-bubble {
border-color: color-mix(in srgb, var(--warn-solid) 42%, transparent);
}
.sx-coach-card.is-degraded .sx-coach-avatar {
background: color-mix(in srgb, var(--clay-tint) 76%, var(--paper-2));
border-color: color-mix(in srgb, var(--clay-deep) 40%, transparent);
}
.sx-coach-card.is-degraded .sx-coach-bubble {
border-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
background: color-mix(in srgb, var(--clay-tint) 22%, var(--paper-2));
}
.sx-coach-card.is-degraded .sx-coach-bubble::before {
background: color-mix(in srgb, var(--clay-tint) 22%, var(--paper-2));
border-left-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
border-bottom-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
}
.sx-coach-bubble__meta {
display: flex;
align-items: center;
@ -1439,6 +1476,78 @@
color: var(--text-strong);
font-size: 12px;
}
.sx-coach-degraded-note {
padding: 8px 9px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--clay-deep) 30%, transparent);
background: color-mix(in srgb, var(--clay-tint) 38%, transparent);
color: var(--clay-deep) !important;
font-size: 11.5px !important;
line-height: 1.45 !important;
}
.sx-coach-persistence-note {
padding: 8px 9px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 30%, transparent);
background: color-mix(in srgb, var(--warn-tint) 58%, transparent);
color: var(--warn-text) !important;
font-size: 11.5px !important;
font-weight: 700;
line-height: 1.45 !important;
}
.sx-coach-quota {
min-height: 26px;
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 7px;
background: color-mix(in srgb, var(--paper) 72%, var(--accent-tint));
color: var(--text-muted);
font-size: 11px;
}
.sx-coach-quota b {
color: var(--accent-deep);
font-family: var(--font-num);
font-size: 11.5px;
font-weight: 800;
font-variant-numeric: tabular-nums;
}
.sx-coach-quota__dots {
display: inline-flex;
align-items: center;
gap: 4px;
}
.sx-coach-quota__dots i {
width: 8px;
height: 8px;
border-radius: 50%;
background: color-mix(in srgb, var(--text-muted) 24%, transparent);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-muted) 18%, transparent);
}
.sx-coach-quota__dots i.is-filled {
background: var(--accent-deep);
box-shadow: 0 0 0 3px var(--accent-tint);
}
.sx-coach-credit-pulse {
justify-self: start;
min-height: 24px;
display: inline-flex;
align-items: center;
padding: 5px 8px;
border-radius: 999px;
background: var(--paper);
color: var(--text-body);
border: 1px solid color-mix(in srgb, var(--text-muted) 22%, transparent);
font: 760 11px/1 var(--font-num);
animation: sxCoachCreditPulse 900ms var(--ease-out) both;
}
.sx-coach-credit-pulse.is-recharge {
color: var(--pos-solid);
border-color: color-mix(in srgb, var(--pos-solid) 34%, transparent);
background: color-mix(in srgb, var(--pos-solid) 11%, var(--paper));
}
.sx-coach-bubble strong {
display: block;
color: var(--text-strong);
@ -1494,6 +1603,20 @@
opacity: 0.9;
}
}
@keyframes sxCoachCreditPulse {
0% {
opacity: 0;
transform: translateY(4px) scale(0.96);
}
35% {
opacity: 1;
transform: translateY(0) scale(1.03);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.sx-coach-history {
position: fixed;
inset: 0;
@ -1535,6 +1658,26 @@
font-size: 12.5px;
line-height: 1.45;
}
.sx-coach-credit-log {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 9px;
}
.sx-coach-credit-log span {
display: inline-flex;
align-items: center;
min-height: 24px;
padding: 4px 8px;
border-radius: 999px;
background: var(--paper-2);
color: var(--text-muted);
font: 720 11px/1 var(--font-num);
}
.sx-coach-credit-log span.is-recharge {
background: color-mix(in srgb, var(--pos-solid) 12%, var(--paper));
color: var(--pos-solid);
}
.sx-coach-history__head button {
width: 34px;
height: 34px;
@ -1568,6 +1711,10 @@
.sx-coach-history__item.is-warn {
border-color: color-mix(in srgb, var(--warn-solid) 40%, transparent);
}
.sx-coach-history__item.is-degraded {
border-color: color-mix(in srgb, var(--clay-deep) 42%, transparent);
background: color-mix(in srgb, var(--clay-tint) 18%, var(--paper));
}
.sx-coach-history__item-top {
display: flex;
align-items: baseline;
@ -1576,6 +1723,18 @@
color: var(--text-muted);
font-size: 11.5px;
}
.sx-coach-degraded-badge {
display: inline-flex;
align-items: center;
min-height: 22px;
padding: 4px 7px;
border-radius: 999px;
background: color-mix(in srgb, var(--clay-tint) 70%, transparent);
color: var(--clay-deep);
font-size: 11px;
line-height: 1;
white-space: nowrap;
}
.sx-coach-history__item-top time {
font-family: var(--font-num);
font-variant-numeric: tabular-nums;
@ -1623,6 +1782,23 @@
font-size: 13px;
text-align: center;
}
.sx-coach-history__empty.is-error {
border: 1px solid color-mix(in srgb, var(--warn-solid) 34%, transparent);
background: color-mix(in srgb, var(--warn-tint) 68%, var(--paper));
color: var(--warn-text);
font-weight: 700;
}
.sx-coach-history__source-alert {
margin-top: 8px;
display: inline-flex;
padding: 6px 8px;
border-radius: 7px;
border: 1px solid color-mix(in srgb, var(--warn-solid) 28%, transparent);
background: color-mix(in srgb, var(--warn-tint) 54%, var(--paper));
color: var(--warn-text);
font-size: 12px;
font-weight: 700;
}
.sx-coach-source-list--compact {
margin-top: 0 !important;
}
@ -2124,6 +2300,10 @@
padding: 7px 12px;
border-radius: 6px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition:
background var(--dur-fast) var(--ease-out),
color var(--dur-fast) var(--ease-out);
@ -2138,6 +2318,28 @@
font-weight: 600;
box-shadow: var(--shadow-sm);
}
.sx-segmented__badge {
min-width: 18px;
height: 18px;
display: inline-grid;
place-items: center;
border-radius: 999px;
background: var(--accent-deep);
color: #fff;
font: 800 10.5px/1 var(--font-num);
font-variant-numeric: tabular-nums;
box-shadow: 0 0 0 2px var(--accent-tint);
}
.sx-segmented button:not(.is-on) .sx-segmented__badge {
background: color-mix(in srgb, var(--accent-deep) 72%, var(--text-muted));
box-shadow: none;
}
.sx-segmented__badge.is-empty,
.sx-segmented button:not(.is-on) .sx-segmented__badge.is-empty {
background: color-mix(in srgb, var(--text-muted) 38%, transparent);
color: var(--text-muted);
box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text-muted) 25%, transparent);
}
.sx-cb-spacer {
flex: 1;
@ -3722,6 +3924,8 @@
.sx-mic.is-on::after,
.sx-utt__caret,
.sx-utt__dots i,
.sx-coach-credit-pulse,
.sx-coach-card.is-loading .sx-coach-avatar__lens,
.sx-transcript__live-dot.is-live {
animation: none !important;
}