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:
parent
93dd8f82d7
commit
16e791e044
390 changed files with 243188 additions and 499 deletions
364
apps/web/e2e/rupture-repair.spec.ts
Normal file
364
apps/web/e2e/rupture-repair.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue