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

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

View file

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