481 lines
16 KiB
TypeScript
481 lines
16 KiB
TypeScript
import type { Page } from "@playwright/test";
|
|
import type {
|
|
SessionReviewResponse,
|
|
UserPrepostMeasureItem,
|
|
UserPrepostMeasuresResponse,
|
|
} from "../src/lib/api";
|
|
|
|
export const FILLED_REVIEW_SESSION_ID =
|
|
"00000000-0000-4000-8000-000000000110";
|
|
export const EMPTY_REVIEW_SESSION_ID =
|
|
"00000000-0000-4000-8000-000000000111";
|
|
export const FILLED_REVIEW_TURN_IDS = [
|
|
"00000000-0000-4000-8000-000000000001",
|
|
"00000000-0000-4000-8000-000000000002",
|
|
"00000000-0000-4000-8000-000000000003",
|
|
"00000000-0000-4000-8000-000000000004",
|
|
"00000000-0000-4000-8000-000000000005",
|
|
"00000000-0000-4000-8000-000000000006",
|
|
] as const;
|
|
|
|
export function filledReviewResponse(
|
|
sessionId: string = FILLED_REVIEW_SESSION_ID,
|
|
): SessionReviewResponse {
|
|
return {
|
|
session_id: sessionId,
|
|
learnerFeedbackEnabled: true,
|
|
client: {
|
|
name: "서연",
|
|
initial: "서",
|
|
persona: "P1 · 고난도 · 인본주의",
|
|
},
|
|
date: "2026-06-27",
|
|
durationLabel: "32분 14초",
|
|
durationSeconds: 1934,
|
|
reachedPhase: "정리",
|
|
sessionSignal: "종료됨",
|
|
supervisorState: "평가 완료",
|
|
supervisorName: "AI",
|
|
summary:
|
|
"초반 방어가 강했지만 학습자가 감정 반영과 침묵 허용을 유지하면서 <hl>관계 신호가 안정적으로 회복된 회기</hl>입니다. 다음 회기에서는 비교 경험의 구체 장면을 더 좁혀 다루는 개입이 필요합니다.",
|
|
phases: [
|
|
{ key: "rapport", label: "라포", weight: 0.22 },
|
|
{ key: "explore", label: "탐색", weight: 0.34 },
|
|
{ key: "intervene", label: "개입", weight: 0.26 },
|
|
{ key: "closing", label: "정리", weight: 0.18 },
|
|
],
|
|
phaseAxis: ["0:00", "7:30", "18:40", "27:10", "32:14"],
|
|
valenceAxis: ["0:00", "8:00", "16:00", "24:00", "32:14"],
|
|
clientValence: [
|
|
{ t: 0, v: -0.48 },
|
|
{ t: 0.18, v: -0.42 },
|
|
{ t: 0.38, v: -0.22 },
|
|
{ t: 0.62, v: 0.02 },
|
|
{ t: 0.82, v: 0.18 },
|
|
{ t: 1, v: 0.12 },
|
|
],
|
|
counselorBaseline: [
|
|
{ t: 0, v: -0.1 },
|
|
{ t: 0.2, v: -0.05 },
|
|
{ t: 0.42, v: 0.03 },
|
|
{ t: 0.64, v: 0.08 },
|
|
{ t: 0.82, v: 0.1 },
|
|
{ t: 1, v: 0.1 },
|
|
],
|
|
turns: [
|
|
{
|
|
id: "t1",
|
|
turn_id: FILLED_REVIEW_TURN_IDS[0],
|
|
ts: "03:12",
|
|
speaker: "client",
|
|
who: "서연",
|
|
text: "엄마가 다른 친구들이랑 비교할 때마다 아무 말도 안 하고 그냥 넘겼어요. 근데 솔직히 계속 제가 이상한 사람 같았어요.",
|
|
techniques: [],
|
|
nonverbal: [{ kind: "silence", label: "침묵", detail: "4.8초" }],
|
|
note: null,
|
|
},
|
|
{
|
|
id: "t2",
|
|
turn_id: FILLED_REVIEW_TURN_IDS[1],
|
|
ts: "05:04",
|
|
speaker: "learner",
|
|
who: "나",
|
|
text: "그 말을 들을 때마다 서연 씨가 혼자서 버티고 있었다는 느낌이 들어요. 비교가 반복될수록 스스로를 의심하게 됐던 걸까요?",
|
|
techniques: [
|
|
{ kind: "reflect", label: "감정 반영" },
|
|
{ kind: "explore", label: "개방 질문" },
|
|
],
|
|
nonverbal: [
|
|
{ kind: "pace", label: "말 속도", detail: "안정" },
|
|
{ kind: "paralinguistic", label: "음성 단서", detail: "한숨 감지 · 신뢰도 82%" },
|
|
],
|
|
note: {
|
|
author: "AI 슈퍼바이저",
|
|
tone: "ai",
|
|
title: "좋은 연결",
|
|
body: "내담자의 경험을 평가하지 않고 정서와 의미를 함께 되짚었습니다.",
|
|
quote: "혼자서 버티고 있었다는 느낌",
|
|
},
|
|
},
|
|
{
|
|
id: "t3",
|
|
turn_id: FILLED_REVIEW_TURN_IDS[2],
|
|
ts: "12:41",
|
|
speaker: "client",
|
|
who: "서연",
|
|
text: "네. 그때는 그냥 화가 난다고 말하면 제가 더 나쁜 사람이 될 것 같아서 아무 말도 못 했어요.",
|
|
techniques: [],
|
|
nonverbal: [{ kind: "audio", label: "음성", detail: "떨림" }],
|
|
note: null,
|
|
},
|
|
{
|
|
id: "t4",
|
|
turn_id: FILLED_REVIEW_TURN_IDS[3],
|
|
ts: "16:05",
|
|
speaker: "learner",
|
|
who: "나",
|
|
text: "화가 났다는 마음 자체가 잘못된 건 아니에요. 그 감정은 서연 씨가 존중받고 싶었다는 신호일 수도 있어요.",
|
|
techniques: [
|
|
{ kind: "empathy", label: "공감" },
|
|
{ kind: "reflect", label: "의미 반영" },
|
|
],
|
|
nonverbal: [],
|
|
note: {
|
|
author: "AI 슈퍼바이저",
|
|
tone: "ai",
|
|
title: "정서 정상화",
|
|
body: "방어를 낮추는 데 유효했습니다. 다만 다음 질문에서 장면을 더 구체화하면 평가 근거가 선명해집니다.",
|
|
quote: "감정은 존중받고 싶었다는 신호",
|
|
},
|
|
},
|
|
{
|
|
id: "t5",
|
|
turn_id: FILLED_REVIEW_TURN_IDS[4],
|
|
ts: "24:18",
|
|
speaker: "learner",
|
|
who: "나",
|
|
text: "다음에 비슷한 말을 듣는다면 바로 반박하기보다, 먼저 어떤 마음이 올라오는지 알아차리는 연습부터 해볼 수 있을까요?",
|
|
techniques: [
|
|
{ kind: "explore", label: "협력적 제안" },
|
|
{ kind: "confront", label: "부드러운 도전" },
|
|
],
|
|
nonverbal: [],
|
|
note: {
|
|
author: "AI 슈퍼바이저",
|
|
tone: "warn",
|
|
title: "속도 점검",
|
|
body: "개입 방향은 적절하지만 내담자의 준비도를 한 번 더 확인하면 좋습니다.",
|
|
quote: "연습부터 해볼 수 있을까요",
|
|
},
|
|
},
|
|
{
|
|
id: "t6",
|
|
turn_id: FILLED_REVIEW_TURN_IDS[5],
|
|
ts: "29:52",
|
|
speaker: "client",
|
|
who: "서연",
|
|
text: "바로 괜찮아질지는 모르겠는데, 적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
|
|
techniques: [],
|
|
nonverbal: [{ kind: "pace", label: "말 속도", detail: "느려짐" }],
|
|
note: null,
|
|
},
|
|
],
|
|
rubric: [
|
|
{ name: "감정 반영", cluster: "정서 확인", ratio: 0.82, quality: "good", freq: "6회" },
|
|
{ name: "개방 질문", cluster: "탐색 촉진", ratio: 0.64, quality: "good", freq: "4회" },
|
|
{ name: "정서 정상화", cluster: "방어 완화", ratio: 0.57, quality: "good", freq: "3회" },
|
|
{ name: "개입 속도", cluster: "준비도 확인", ratio: 0.35, quality: "watch", freq: "1회" },
|
|
],
|
|
goodMoments: [
|
|
{
|
|
title: "방어를 낮춘 감정 반영",
|
|
body: "비교 경험을 바로 해석하지 않고 감정과 의미를 먼저 되짚었습니다.",
|
|
jumpTo: "t2",
|
|
},
|
|
{
|
|
title: "정서 정상화",
|
|
body: "화가 잘못이 아니라 욕구 신호일 수 있다고 재구성했습니다.",
|
|
jumpTo: "t4",
|
|
},
|
|
],
|
|
growthPoints: [
|
|
{
|
|
title: "개입 전 준비도 확인",
|
|
body: "행동 제안 전에 내담자가 지금 변화를 다룰 여지가 있는지 확인해야 합니다.",
|
|
jumpTo: "t5",
|
|
},
|
|
{
|
|
title: "비교 장면 좁히기",
|
|
body: "엄마의 말, 그때 떠오른 생각, 몸 반응을 분리하면 사례개념화 근거가 선명해집니다.",
|
|
jumpTo: "t3",
|
|
},
|
|
],
|
|
caseWorksheet: {
|
|
status: "draft_from_transcript",
|
|
generatedBy: "rule-based transcript extractor",
|
|
savedAt: null,
|
|
sections: [
|
|
{
|
|
key: "trigger",
|
|
title: "촉발 장면",
|
|
items: [
|
|
{
|
|
key: "comparison",
|
|
label: "반복 비교",
|
|
value: "엄마가 친구들과 비교하는 말을 반복할 때 자기비난과 분노가 함께 올라옴.",
|
|
evidence: [
|
|
{
|
|
turnId: "t1",
|
|
speaker: "client",
|
|
quote: "다른 친구들이랑 비교할 때마다",
|
|
},
|
|
],
|
|
confidence: "medium",
|
|
emptyReason: null,
|
|
},
|
|
],
|
|
},
|
|
{
|
|
key: "emotion",
|
|
title: "핵심 정서",
|
|
items: [
|
|
{
|
|
key: "anger_shame",
|
|
label: "분노와 수치심",
|
|
value: "화가 나지만 표현하면 나쁜 사람이 될 것 같아 억제함.",
|
|
evidence: [
|
|
{
|
|
turnId: "t3",
|
|
speaker: "client",
|
|
quote: "제가 더 나쁜 사람이 될 것 같아서",
|
|
},
|
|
],
|
|
confidence: "medium",
|
|
emptyReason: null,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
limitations: [
|
|
"가족관계 정보는 이번 회기 발화에 한정됩니다.",
|
|
"위험도 평가는 별도 안전평가 결과와 함께 확인해야 합니다.",
|
|
],
|
|
},
|
|
firstSessionChecklist: {
|
|
version: "first-session-rapport-open-question.v1",
|
|
applicable: true,
|
|
status: "ready",
|
|
title: "첫 회기 라포·개방질문 체크리스트",
|
|
note: "축어록에서 관찰 가능한 대화 행동을 규칙 기반으로 점검하며, 임상 평가나 성적 판정이 아닙니다.",
|
|
criteria: [
|
|
{
|
|
criterionId: "first-session.rapport-reflection",
|
|
label: "라포 형성 반영",
|
|
description: "내담자의 감정이나 경험을 평가 없이 되짚는 발화를 확인합니다.",
|
|
status: "met",
|
|
evidenceTurns: [
|
|
{
|
|
turnId: "t2",
|
|
turnSeq: 2,
|
|
quote: "혼자서 버티고 있었다는 느낌이 들어요.",
|
|
},
|
|
],
|
|
},
|
|
{
|
|
criterionId: "first-session.open-question-one-focus",
|
|
label: "한 초점 개방질문",
|
|
description: "한 번에 한 초점을 묻는 개방형 질문 발화를 확인합니다.",
|
|
status: "met",
|
|
evidenceTurns: [
|
|
{
|
|
turnId: "t2",
|
|
turnSeq: 2,
|
|
quote: "비교가 반복될수록 스스로를 의심하게 됐던 걸까요?",
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
nextLine:
|
|
"그 말을 들은 바로 그 순간, 몸에서는 어떤 반응이 먼저 올라왔나요?",
|
|
clientFeedback:
|
|
"처음에는 말하기 싫었는데, 화가 이상한 게 아니라는 말은 조금 안심됐어요.",
|
|
audioUrl: "/mock/reviews/filled-review-visual.mp3",
|
|
pdfExportUrl: "/mock/reviews/filled-review-visual.pdf",
|
|
degraded: false,
|
|
reviewReady: true,
|
|
};
|
|
}
|
|
|
|
export async function routeFilledSessionReview(
|
|
page: Page,
|
|
sessionId: string = FILLED_REVIEW_SESSION_ID,
|
|
) {
|
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(filledReviewResponse(sessionId)),
|
|
});
|
|
});
|
|
await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify({ items: [] }),
|
|
});
|
|
});
|
|
}
|
|
|
|
export function prepostMeasuresResponse(
|
|
measures: UserPrepostMeasureItem[] = [
|
|
{
|
|
measure_id: "prepost-self-efficacy-pre",
|
|
pilot_id: "phase3-pilot-draft",
|
|
measure_name: "self_efficacy",
|
|
timepoint: "pre",
|
|
raw_score: 3,
|
|
min_score: 1,
|
|
max_score: 5,
|
|
normalized_score: 0.5,
|
|
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
|
item_count: 1,
|
|
collected_at: 1782662400,
|
|
updated_at: 1782662400,
|
|
},
|
|
{
|
|
measure_id: "prepost-self-efficacy-post",
|
|
pilot_id: "phase3-pilot-draft",
|
|
measure_name: "self_efficacy",
|
|
timepoint: "post",
|
|
raw_score: 4,
|
|
min_score: 1,
|
|
max_score: 5,
|
|
normalized_score: 0.75,
|
|
instrument_version: "pilot-prepost-scaffold-2026-06-28",
|
|
item_count: 1,
|
|
collected_at: 1782662400,
|
|
updated_at: 1782662400,
|
|
},
|
|
],
|
|
): UserPrepostMeasuresResponse {
|
|
const completePairs = new Set<string>();
|
|
for (const measureName of ["self_efficacy", "skill_proficiency", "training_satisfaction"]) {
|
|
const hasPre = measures.some(
|
|
(item) => item.measure_name === measureName && item.timepoint === "pre",
|
|
);
|
|
const hasPost = measures.some(
|
|
(item) => item.measure_name === measureName && item.timepoint === "post",
|
|
);
|
|
if (hasPre && hasPost) completePairs.add(measureName);
|
|
}
|
|
return {
|
|
source: "database",
|
|
durable: true,
|
|
pilot_id: "phase3-pilot-draft",
|
|
required_measure_names: [
|
|
"self_efficacy",
|
|
"skill_proficiency",
|
|
"training_satisfaction",
|
|
],
|
|
required_timepoints: ["pre", "post"],
|
|
complete_measure_pairs: completePairs.size,
|
|
measures,
|
|
generated_at: 1782662400,
|
|
};
|
|
}
|
|
|
|
export async function routePrepostMeasures(page: Page) {
|
|
let measures = prepostMeasuresResponse().measures;
|
|
|
|
await page.route("**/api/users/me/prepost-measures**", async (route) => {
|
|
const request = route.request();
|
|
if (request.method() === "GET") {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(prepostMeasuresResponse(measures)),
|
|
});
|
|
return;
|
|
}
|
|
|
|
if (request.method() === "PUT") {
|
|
const body = request.postDataJSON() as {
|
|
measure_name: UserPrepostMeasureItem["measure_name"];
|
|
timepoint: UserPrepostMeasureItem["timepoint"];
|
|
raw_score: number;
|
|
min_score?: number;
|
|
max_score?: number;
|
|
instrument_version?: string;
|
|
item_count?: number;
|
|
pilot_id?: string;
|
|
};
|
|
const now = 1782666000;
|
|
const next: UserPrepostMeasureItem = {
|
|
measure_id: `prepost-${body.measure_name}-${body.timepoint}`,
|
|
pilot_id: body.pilot_id ?? "phase3-pilot-draft",
|
|
measure_name: body.measure_name,
|
|
timepoint: body.timepoint,
|
|
raw_score: body.raw_score,
|
|
min_score: body.min_score ?? 1,
|
|
max_score: body.max_score ?? 5,
|
|
normalized_score:
|
|
(body.raw_score - (body.min_score ?? 1)) / ((body.max_score ?? 5) - (body.min_score ?? 1)),
|
|
instrument_version: body.instrument_version ?? "pilot-prepost-scaffold-2026-06-28",
|
|
item_count: body.item_count ?? 1,
|
|
collected_at: now,
|
|
updated_at: now,
|
|
};
|
|
measures = measures
|
|
.filter(
|
|
(item) =>
|
|
!(
|
|
item.measure_name === next.measure_name &&
|
|
item.timepoint === next.timepoint &&
|
|
item.instrument_version === next.instrument_version
|
|
),
|
|
)
|
|
.concat(next);
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(next),
|
|
});
|
|
return;
|
|
}
|
|
|
|
await route.fallback();
|
|
});
|
|
}
|
|
|
|
export function emptyReviewResponse(
|
|
sessionId: string = EMPTY_REVIEW_SESSION_ID,
|
|
): SessionReviewResponse {
|
|
const response = filledReviewResponse(sessionId);
|
|
return {
|
|
...response,
|
|
date: "2026-06-28",
|
|
durationLabel: "0분",
|
|
durationSeconds: 0,
|
|
reachedPhase: "라포",
|
|
sessionSignal: "종료됨",
|
|
supervisorState: "평가 대기",
|
|
summary:
|
|
"축어록이 아직 저장되지 않아 평가 근거를 생성하지 않았습니다. 빈 상태에서는 임의의 강점이나 개선점을 표시하지 않습니다.",
|
|
phases: [],
|
|
phaseAxis: [],
|
|
valenceAxis: [],
|
|
clientValence: [],
|
|
counselorBaseline: [],
|
|
turns: [],
|
|
rubric: [],
|
|
goodMoments: [],
|
|
growthPoints: [],
|
|
caseWorksheet: {
|
|
status: "empty",
|
|
generatedBy: "rule-based transcript extractor",
|
|
savedAt: null,
|
|
sections: [],
|
|
limitations: ["축어록이 없어 사례개념화 워크시트를 생성하지 않았습니다."],
|
|
},
|
|
nextLine: null,
|
|
clientFeedback: null,
|
|
audioUrl: null,
|
|
pdfExportUrl: null,
|
|
degraded: false,
|
|
reviewReady: false,
|
|
};
|
|
}
|
|
|
|
export async function routeEmptySessionReview(
|
|
page: Page,
|
|
sessionId: string = EMPTY_REVIEW_SESSION_ID,
|
|
) {
|
|
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
|
|
await route.fulfill({
|
|
status: 200,
|
|
contentType: "application/json",
|
|
body: JSON.stringify(emptyReviewResponse(sessionId)),
|
|
});
|
|
});
|
|
}
|