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: "초반 방어가 강했지만 학습자가 감정 반영과 침묵 허용을 유지하면서 관계 신호가 안정적으로 회복된 회기입니다. 다음 회기에서는 비교 경험의 구체 장면을 더 좁혀 다루는 개입이 필요합니다.", 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(); 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)), }); }); await page.route(`**/api/sessions/${sessionId}/alliance-pulses`, async (route) => { await route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify({ items: [] }), }); }); await page.route( new RegExp(`/api/sessions/${sessionId}/multimodal-alliance\\?include_derived=true$`), async (route) => { await route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: "multimodal alliance ledger not found" }), }); }, ); await page.route(`**/api/sessions/${sessionId}/outcome-trajectory`, async (route) => { await route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: "outcome trajectory not found" }), }); }); await page.route(`**/api/sessions/${sessionId}/ruptures`, async (route) => { await route.fulfill({ status: 404, contentType: "application/json", body: JSON.stringify({ detail: "rupture ledger not found" }), }); }); }