세션 평가와 교수자 분석 보강
This commit is contained in:
parent
5c4ac04e06
commit
fe2796f05a
51 changed files with 4928 additions and 240 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue