G0~G8 성과·동맹 측정 OS 작업 일괄 고정

8월 7일까지 워킹트리에만 남아 있던 미커밋 작업을 커밋한다. 여러 사본
폴더(worktree·clone)에 흩어져 있던 중간 스냅샷을 정리하기 전에 원본을
git 이력으로 고정하는 것이 목적이다.

- contracts/routes/services: measurement, outcome_trajectory, rupture_repair,
  deliberate_practice, calibration_transfer, supervision_research,
  multimodal_alliance, continuous_improvement 계열 신규 모듈과 테스트
- infra/db/init: 07~16 마이그레이션(측정 기반~calibration transfer 실행)
- apps/web: 세션 리뷰 카드·관리 화면·E2E 스펙 추가
- docs/ops: G0~G8 라이브 통합·배포·롤백 증거 문서와 evidence JSON/PNG
- scripts: smoke·ledger·릴리스 에이전트·NAS 프리뷰 운영 스크립트

engine.public 로그 .bak과 apps/web/test-results 산출물은 커밋에서 제외했다.
This commit is contained in:
Yun Chan 2026-08-08 01:30:53 +09:00
parent 93dd8f82d7
commit 16e791e044
390 changed files with 243188 additions and 499 deletions

View file

@ -0,0 +1,364 @@
import { expect, test, type Page, type Route } from "@playwright/test";
import type {
RuptureEpisode,
RuptureObservation,
RuptureRepairReadModel,
} from "../src/pages/session-review/ruptureRepairApi";
import {
FILLED_REVIEW_SESSION_ID,
filledReviewResponse,
routePrepostMeasures,
} from "./session-review-fixture";
import { expectNoHorizontalOverflow } from "./support";
type ReviewRole = "learner" | "teacher";
const TURN_UUIDS = [
"30000000-0000-4000-8000-000000000001",
"30000000-0000-4000-8000-000000000002",
"30000000-0000-4000-8000-000000000003",
"30000000-0000-4000-8000-000000000004",
"30000000-0000-4000-8000-000000000005",
"30000000-0000-4000-8000-000000000006",
];
const EPISODE_ID = "31000000-0000-4000-8000-000000000001";
const OBSERVATION_ONE_ID = "32000000-0000-4000-8000-000000000001";
const OBSERVATION_TWO_ID = "32000000-0000-4000-8000-000000000002";
const CORRECTION_ID = "32000000-0000-4000-8000-000000000003";
async function fulfillJson(route: Route, body: unknown, status = 200) {
await route.fulfill({
status,
contentType: "application/json",
body: JSON.stringify(body),
});
}
function observations(): RuptureObservation[] {
return [
{
observation_id: OBSERVATION_ONE_ID,
episode_id: EPISODE_ID,
sequence_no: 1,
event_kind: "rupture.detected",
from_state: null,
to_state: "onset",
rupture_type: "withdrawal",
source_kind: "observed_runtime",
perspective: "runtime_observation",
ai_view: "evaluator",
confidence: 0.81,
uncertainty: 0.19,
evidence_turn_ids: [TURN_UUIDS[1]],
counterevidence: [],
model_run_id: null,
supersedes_observation_id: null,
correction_reason: null,
created_at: "2026-08-06T09:10:00Z",
},
{
observation_id: OBSERVATION_TWO_ID,
episode_id: EPISODE_ID,
sequence_no: 2,
event_kind: "repair.partial",
from_state: "repair_attempted",
to_state: "partial",
rupture_type: "withdrawal",
source_kind: "model_inferred",
perspective: "independent_observer",
ai_view: "evaluator",
confidence: 0.78,
uncertainty: 0.22,
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
counterevidence: ["회기 말에는 내담자가 다시 감정을 설명했습니다."],
model_run_id: "33000000-0000-4000-8000-000000000001",
supersedes_observation_id: null,
correction_reason: null,
created_at: "2026-08-06T09:14:00Z",
},
];
}
function episode(corrected = false): RuptureEpisode {
const baseObservations = observations();
const correction: RuptureObservation = {
observation_id: CORRECTION_ID,
episode_id: EPISODE_ID,
sequence_no: 3,
event_kind: "human.corrected",
from_state: "partial",
to_state: "resolved",
rupture_type: "withdrawal",
source_kind: "human_rated",
perspective: "supervisor_human",
ai_view: "supervisor",
confidence: null,
uncertainty: 0.1,
evidence_turn_ids: [TURN_UUIDS[3]],
counterevidence: ["내담자의 후속 반응이 안정적으로 이어졌습니다."],
model_run_id: null,
supersedes_observation_id: OBSERVATION_TWO_ID,
correction_reason: "후속 반응 근거를 반영해 수선 확인으로 정정했습니다.",
created_at: "2026-08-06T09:18:00Z",
};
return {
episode_id: EPISODE_ID,
session_id: FILLED_REVIEW_SESSION_ID,
case_id: "34000000-0000-4000-8000-000000000001",
learner_id: "34000000-0000-4000-8000-000000000002",
episode_key: "withdrawal-after-premature-advice",
created_at: "2026-08-06T09:10:00Z",
rupture_type: "withdrawal",
current_status: corrected ? "resolved" : "partial",
status_source: corrected ? "human_correction" : "deep_reconciliation",
observations: corrected ? [...baseObservations, correction] : baseObservations,
reconciliation_revisions: [
{
revision_id: "35000000-0000-4000-8000-000000000001",
episode_id: EPISODE_ID,
revision_no: 1,
supersedes_revision_id: null,
fast_warning_observation_id: OBSERVATION_ONE_ID,
deep_observation_id: OBSERVATION_TWO_ID,
fast_warning_id: "fast-warning-17",
provisional_status: "missed",
deep_status: "partial",
disposition: "superseded_partial",
uncertainty: 0.22,
evidence_turn_ids: [TURN_UUIDS[2], TURN_UUIDS[3]],
counterevidence: ["회기 말에는 내담자가 다시 감정을 설명했습니다."],
model_run_id: "33000000-0000-4000-8000-000000000001",
created_at: "2026-08-06T09:16:00Z",
},
],
safety_references: [
{
episode_id: EPISODE_ID,
safety_event_id: 71,
turn_id: TURN_UUIDS[4],
ko_risk_level: 2,
escalated: true,
created_at: "2026-08-06T09:12:00Z",
},
],
};
}
function readModel(role: ReviewRole, corrected = false): RuptureRepairReadModel {
return {
session_id: FILLED_REVIEW_SESSION_ID,
requested_view: role === "teacher" ? "supervisor" : "counselor",
clinical_claim_allowed: false,
episodes: [episode(corrected)],
};
}
async function routeReviewUser(page: Page, role: ReviewRole) {
await page.route("**/api/auth/me", (route) =>
fulfillJson(route, {
user_id:
role === "teacher"
? "00000000-0000-0000-0000-000000000202"
: "00000000-0000-0000-0000-000000000101",
email: `${role}@hs.ac.kr`,
role,
display_name: role === "teacher" ? "E2E Teacher" : "E2E Learner",
admin_access: false,
super_admin: false,
account_status: "approved",
approval_required: false,
cohort_ids: ["e2e-hanshin"],
consent_at: 1782820000,
onboarding_completed_at: 1782820001,
nickname: role === "teacher" ? "E2E Teacher" : "E2E Learner",
self_introduction: "",
avatar_url: "",
}),
);
}
async function routeReviewPage(page: Page, role: ReviewRole) {
const review = filledReviewResponse(FILLED_REVIEW_SESSION_ID);
review.turns = review.turns.map((turn, index) => ({
...turn,
turn_id: TURN_UUIDS[index] ?? null,
}));
if (role === "teacher") {
review.teacherReview = {
status: "viewed",
note: "",
reviewedAt: null,
reviewerId: "00000000-0000-0000-0000-000000000202",
worksheetStatus: "pending",
worksheetNote: "",
worksheetReviewedAt: null,
};
}
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/review`, (route) =>
fulfillJson(route, review),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/alliance-pulses`,
(route) => fulfillJson(route, { items: [] }),
);
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/outcome-trajectory`,
(route) => fulfillJson(route, { detail: "not found" }, 404),
);
await routePrepostMeasures(page);
}
async function prepareReview(page: Page, role: ReviewRole) {
await routeReviewUser(page, role);
await routeReviewPage(page, role);
}
async function openFeedback(page: Page, role: ReviewRole) {
const root = role === "teacher" ? "/teach/session" : "/learn/session";
await page.goto(`${root}/${FILLED_REVIEW_SESSION_ID}/review`);
await page.getByRole("tab", { name: "피드백" }).click();
return page.locator(".rr-card");
}
test.describe("G3 균열과 수선 원장", () => {
test("학습자에게 현재 판정, 출처, 근거, 안전 원장과 로컬 연습 준비를 보여준다", async ({
page,
}, testInfo) => {
await prepareReview(page, "learner");
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
fulfillJson(route, readModel("learner")),
);
const card = await openFeedback(page, "learner");
await expect(card.getByRole("heading", { name: "관계가 어긋난 장면과 다시 맞춘 근거를 봅니다" })).toBeVisible();
await expect(card.getByText("학습자 역할 보기", { exact: true })).toBeVisible();
await expect(card.getByText("철수형 균열", { exact: true })).toBeVisible();
await expect(card.getByText("부분 수선", { exact: true }).first()).toBeVisible();
await expect(card.getByText("불확실성")).toBeVisible();
await expect(card.getByText("22%", { exact: true })).toBeVisible();
await expect(card.getByText("빠른 경고", { exact: true })).toBeVisible();
await expect(
card.getByLabel("빠른 경고와 깊은 재조정 출처").getByText("깊은 재조정", { exact: true }),
).toBeVisible();
await expect(card.getByText("부분 수선으로 갱신", { exact: false })).toBeVisible();
await expect(card.getByRole("heading", { name: "현재 판정 근거" })).toBeVisible();
await expect(card.getByRole("heading", { name: "반대 근거" })).toBeVisible();
await expect(card.getByRole("heading", { name: "안전 원장" })).toBeVisible();
await expect(card.getByText("균열·수선 판정과 합산하지 않는 별도 확인 영역입니다.")).toBeVisible();
await expect(card.getByText(/총점|평균/)).toHaveCount(0);
await expect(card.getByText("사람 판정으로 정정")).toHaveCount(0);
const practice = card.locator(".rr-practice");
const firstItem = practice.getByLabel("균열이 시작된 발화를 다시 확인하기");
await firstItem.check();
await expect(firstItem).toBeChecked();
await page.reload();
await page.getByRole("tab", { name: "피드백" }).click();
await expect(page.locator(".rr-practice").getByLabel("균열이 시작된 발화를 다시 확인하기")).toBeChecked();
const refreshedCard = page.locator(".rr-card");
await refreshedCard.getByRole("button", { name: /발화로 이동/ }).first().click();
await expect(page.getByRole("tab", { name: "축어록" })).toHaveAttribute("aria-selected", "true");
await expect(page.locator(".sr-turn--active")).toHaveCount(1);
await page.getByRole("tab", { name: "피드백" }).click();
await refreshedCard.locator(".rr-provenance").screenshot({
path: testInfo.outputPath(`rupture-provenance-${testInfo.project.name}.png`),
animations: "disabled",
});
await refreshedCard.locator(".rr-evidence-grid").screenshot({
path: testInfo.outputPath(`rupture-evidence-${testInfo.project.name}.png`),
animations: "disabled",
});
await refreshedCard.locator(".rr-practice").screenshot({
path: testInfo.outputPath(`rupture-practice-${testInfo.project.name}.png`),
animations: "disabled",
});
await expectNoHorizontalOverflow(page);
});
test("404와 역할별 빈 원장은 차분한 빈 상태로 처리한다", async ({ page }) => {
await prepareReview(page, "learner");
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
fulfillJson(route, { detail: "rupture ledger not found" }, 404),
);
const card = await openFeedback(page, "learner");
await expect(card.getByRole("heading", { name: "아직 검토할 관계 장면이 없습니다" })).toBeVisible();
await expect(card.getByRole("button", { name: "다시 불러오기" })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("교수자는 최신 관찰을 supersede하고 성공 뒤 read model을 다시 불러온다", async ({
page,
}, testInfo) => {
await prepareReview(page, "teacher");
let corrected = false;
let getCount = 0;
const submissions: Array<Record<string, unknown>> = [];
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, async (route) => {
getCount += 1;
await fulfillJson(route, readModel("teacher", corrected));
});
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`,
async (route) => {
submissions.push(route.request().postDataJSON() as Record<string, unknown>);
corrected = true;
await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201);
},
);
const card = await openFeedback(page, "teacher");
await expect(card.getByText("교수자 역할 보기", { exact: true })).toBeVisible();
await expect(card.locator(".rr-practice")).toHaveCount(0);
await card.getByText("사람 판정으로 정정", { exact: true }).click();
const form = card.locator(".rr-correction form");
await form.getByLabel("정정 상태").selectOption("resolved");
await form.getByLabel("정정 이유").fill("후속 반응이 안정적으로 이어진 근거를 반영합니다.");
await form.getByRole("button", { name: "정정 기록 추가" }).click();
await expect(form.getByText("사람 판정을 새 관찰로 추가하고 최신 원장을 다시 불러왔습니다.")).toBeVisible();
await expect(card.getByText("사람 판정이 최신", { exact: true })).toBeVisible();
await expect(card.getByText("수선 확인", { exact: true }).first()).toBeVisible();
expect(getCount).toBeGreaterThanOrEqual(2);
expect(submissions).toHaveLength(1);
expect(submissions[0].supersedes_observation_id).toBe(OBSERVATION_TWO_ID);
expect(submissions[0].evidence_turn_ids).toEqual([TURN_UUIDS[2], TURN_UUIDS[3]]);
await card.locator(".rr-correction").screenshot({
path: testInfo.outputPath(`rupture-correction-${testInfo.project.name}.png`),
animations: "disabled",
});
await expectNoHorizontalOverflow(page);
});
test("정정 오류 재시도는 동일 idempotency UUID와 본문을 보존한다", async ({ page }) => {
await prepareReview(page, "teacher");
await page.route(`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures`, (route) =>
fulfillJson(route, readModel("teacher")),
);
const submissions: Array<Record<string, unknown>> = [];
await page.route(
`**/api/sessions/${FILLED_REVIEW_SESSION_ID}/ruptures/${EPISODE_ID}/corrections`,
async (route) => {
submissions.push(route.request().postDataJSON() as Record<string, unknown>);
if (submissions.length === 1) {
await fulfillJson(route, { detail: "temporary correction failure" }, 503);
return;
}
await fulfillJson(route, { episode_id: EPISODE_ID, observation_id: CORRECTION_ID }, 201);
},
);
const card = await openFeedback(page, "teacher");
await card.getByText("사람 판정으로 정정", { exact: true }).click();
const form = card.locator(".rr-correction form");
await form.getByLabel("정정 이유").fill("사람 검토 근거를 반영합니다.");
await form.getByRole("button", { name: "정정 기록 추가" }).click();
await expect(form.getByRole("alert")).toContainText("API 503");
await form.getByRole("button", { name: "같은 요청 다시 제출" }).click();
await expect(form.getByText(/최신 원장을 다시 불러왔습니다/)).toBeVisible();
expect(submissions).toHaveLength(2);
expect(submissions[1]).toEqual(submissions[0]);
expect(submissions[1].idempotency_key).toBe(submissions[0].idempotency_key);
});
});