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

- 누적 작업트리 커밋: 회기 평가 복구·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,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);
}
});
});
});