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,518 @@
import { readFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type APIResponse, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi } from "./support";
const DATA_CLASSIFICATION = "synthetic_replay_red_team_coverage_drift";
const INTERNAL_HEADER = "X-Vignette-Continuous-Improvement-Token";
const SOURCE_PATH = path.resolve(
process.cwd(),
"../api/app/data/continuous_improvement/synthetic_source_pack.v2.json",
);
type Role = "admin" | "teacher" | "learner";
interface RepoSourceSpec {
data_classification: typeof DATA_CLASSIFICATION;
content_kind: "case" | "rupture" | "practice" | "benchmark";
difficulty_level: number;
variant_count: number;
prompt_version: string;
source_packs: Array<{
artifact: {
source_id: string;
version: string;
content_sha256: string;
provenance_uri: string;
usage_status: "approved";
citation_label: string;
};
content: string;
}>;
}
interface AgenticPipelineResponse {
submission_id: string;
pipeline_id: string;
qualification_id: string;
candidate_catalog_entry_id: string;
state: "pending_human_approval";
human_approval_required: true;
catalog_promoted: false;
idempotent_replay: boolean;
clinical_claim_allowed: false;
agent_calls_executed: number;
}
interface ContinuousImprovementView {
content_qualifications: Array<{
qualification_id: string;
catalog_entry_id: string;
source_provenance_uris: string[];
draft_payload: Record<string, unknown> | null;
}>;
approvals: Array<{
approval_event_id: string;
target_kind: string;
target_id: string;
decision: string;
}>;
catalog_entries: Array<{
catalog_record_id: string;
qualification_id: string;
catalog_entry_id: string;
status: "approved";
clinical_claim_allowed: false;
}>;
data_classification: typeof DATA_CLASSIFICATION;
silent_auto_promotion_allowed: false;
raw_transcript_included: false;
pii_included: false;
clinical_claim_allowed: false;
}
interface ApprovedCatalogResponse {
entries: Array<{
catalog_record_id: string;
qualification_id: string;
catalog_entry_id: string;
payload: Record<string, unknown>;
status: "approved";
clinical_claim_allowed: false;
}>;
data_classification: typeof DATA_CLASSIFICATION;
human_approval_required: true;
raw_transcript_included: false;
pii_included: false;
clinical_claim_allowed: false;
}
async function expectOk(response: APIResponse) {
expect(response.ok(), await response.text()).toBeTruthy();
}
function suffixFor(testInfo: { workerIndex: number; retry: number }) {
return `live-${Date.now().toString(36)}-${testInfo.workerIndex}-${testInfo.retry}`;
}
async function signIn(page: Page, role: Role, suffix: string) {
const domain = role === "admin" ? "twentyoz.kr" : "hs.ac.kr";
const login = await page.request.post("/api/auth/dev-login", {
data: {
email: `g8-${role}-${suffix}@${domain}`,
role,
display_name: `G8 ${role}`,
cohort_ids: ["e2e-hanshin"],
},
});
await expectOk(login);
const onboarding = await page.request.post("/api/users/me/onboarding", {
data: {
legal_name: `G8 ${role}`,
affiliation: "한신대학교",
department: role === "admin" ? "운영" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : role === "teacher" ? "교수" : "3학년",
phone: "010-0000-0000",
contact_address: "경기도 오산시 한신대학교",
nickname: `G8 ${role}`,
self_introduction: "합성 지속 개선 게이트 검증 계정입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
},
});
await expectOk(onboarding);
}
function matching<T extends { qualification_id: string }>(
rows: T[],
qualificationId: string,
): T[] {
return rows.filter((row) => row.qualification_id === qualificationId);
}
function assertNoSensitivePayload(value: unknown, location = "response") {
if (Array.isArray(value)) {
value.forEach((item, index) => assertNoSensitivePayload(item, `${location}[${index}]`));
return;
}
if (!value || typeof value !== "object") return;
const record = value as Record<string, unknown>;
const forbidden = [
"hidden_answer",
"raw_transcript",
"transcript",
"utterance_text",
"clinical_diagnosis",
"treatment_plan",
];
for (const key of forbidden) {
expect(record, `${location} exposed ${key}`).not.toHaveProperty(key);
}
for (const key of ["raw_transcript_included", "pii_included", "clinical_claim_allowed"]) {
if (key in record) expect(record[key], `${location}.${key}`).toBe(false);
}
for (const [key, child] of Object.entries(record)) {
assertNoSensitivePayload(child, `${location}.${key}`);
}
}
test.describe("continuous improvement human catalog gate (real API/DB)", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
});
test("reviews a repo-approved agentic payload in the admin browser and exposes exactly one approved catalog entry @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(8 * 60_000);
test.skip(
process.env.E2E_G8_AGENTIC_APPROVAL !== "1",
"Set E2E_G8_AGENTIC_APPROVAL=1 for the explicit real-engine approval gate.",
);
const internalToken = process.env.E2E_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN ?? "";
expect(internalToken.length, "G8 internal token must be injected without logging it").toBeGreaterThanOrEqual(
32,
);
const healthResponse = await page.request.get("/api/health");
await expectOk(healthResponse);
const health = (await healthResponse.json()) as { db: boolean; engine: boolean };
expect(health).toMatchObject({ db: true, engine: true });
const sourceSpec = JSON.parse(await readFile(SOURCE_PATH, "utf8")) as RepoSourceSpec;
expect(sourceSpec.data_classification).toBe(DATA_CLASSIFICATION);
expect(sourceSpec.source_packs).toHaveLength(1);
expect(sourceSpec.source_packs[0].artifact).toMatchObject({
usage_status: "approved",
provenance_uri:
"repo://apps/api/app/data/continuous_improvement/synthetic_source_pack.v2.json",
});
const suffix = suffixFor(testInfo);
const requestBody = {
submission_id: crypto.randomUUID(),
pipeline_id: crypto.randomUUID(),
benchmark_record_id: crypto.randomUUID(),
qualification_id: crypto.randomUUID(),
data_classification: sourceSpec.data_classification,
source_packs: sourceSpec.source_packs,
content_kind: sourceSpec.content_kind,
difficulty_level: sourceSpec.difficulty_level,
variant_count: sourceSpec.variant_count,
prompt_version: sourceSpec.prompt_version,
};
const agenticPath = "/api/internal/continuous-improvement/agentic-content-pipelines";
const internalHeaders = { [INTERNAL_HEADER]: internalToken };
const create = await page.request.post(agenticPath, {
data: requestBody,
headers: internalHeaders,
timeout: 5 * 60_000,
});
await expectOk(create);
expect(create.status()).toBe(201);
const candidate = (await create.json()) as AgenticPipelineResponse;
expect(candidate).toMatchObject({
submission_id: requestBody.submission_id,
qualification_id: requestBody.qualification_id,
state: "pending_human_approval",
human_approval_required: true,
catalog_promoted: false,
idempotent_replay: false,
clinical_claim_allowed: false,
});
expect(candidate.agent_calls_executed).toBeGreaterThanOrEqual(7);
const agenticReplay = await page.request.post(agenticPath, {
data: requestBody,
headers: internalHeaders,
timeout: 60_000,
});
await expectOk(agenticReplay);
const replayedCandidate = (await agenticReplay.json()) as AgenticPipelineResponse;
expect(replayedCandidate).toMatchObject({
qualification_id: candidate.qualification_id,
candidate_catalog_entry_id: candidate.candidate_catalog_entry_id,
idempotent_replay: true,
agent_calls_executed: 0,
});
const changedAgentic = await page.request.post(agenticPath, {
data: { ...requestBody, difficulty_level: requestBody.difficulty_level === 5 ? 4 : 5 },
headers: internalHeaders,
timeout: 60_000,
});
expect(changedAgentic.status(), await changedAgentic.text()).toBe(409);
const approvalBody = {
submission_id: crypto.randomUUID(),
approval_event_id: crypto.randomUUID(),
effect_record_id: crypto.randomUUID(),
target_kind: "content_qualification",
target_id: candidate.qualification_id,
decision: "approve_content",
reason_code: "repo-approved synthetic payload의 경계와 수련 목표를 브라우저에서 검수함",
evidence_refs: [
sourceSpec.source_packs[0].artifact.provenance_uri,
`audit://continuous-improvement/human-review/${candidate.qualification_id}`,
],
};
for (const role of ["learner", "teacher"] as const) {
await signIn(page, role, `${suffix}-${role}`);
const blockedRead = await page.request.get("/api/continuous-improvement");
expect(blockedRead.status(), await blockedRead.text()).toBe(403);
const blockedCatalog = await page.request.get("/api/continuous-improvement/catalog");
expect(blockedCatalog.status(), await blockedCatalog.text()).toBe(403);
const blockedApproval = await page.request.post("/api/continuous-improvement/approvals", {
data: approvalBody,
});
expect(blockedApproval.status(), await blockedApproval.text()).toBe(403);
}
await signIn(page, "admin", `${suffix}-admin`);
const beforeViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(beforeViewResponse);
const beforeView = (await beforeViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(beforeView);
expect(matching(beforeView.catalog_entries, candidate.qualification_id)).toHaveLength(0);
const qualification = beforeView.content_qualifications.find(
(item) => item.qualification_id === candidate.qualification_id,
);
expect(qualification).toBeTruthy();
expect(qualification?.draft_payload).not.toBeNull();
expect(qualification?.source_provenance_uris).toContain(
sourceSpec.source_packs[0].artifact.provenance_uri,
);
const beforeCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(beforeCatalogResponse);
const beforeCatalog = (await beforeCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(beforeCatalog);
expect(matching(beforeCatalog.entries, candidate.qualification_id)).toHaveLength(0);
await page.goto("/admin/continuous-improvement");
await expect(page.getByRole("heading", { name: "승격보다 근거를 먼저 본다" })).toBeVisible();
const card = page.locator(`[data-qualification-id="${candidate.qualification_id}"]`);
await expect(card).toBeVisible();
await expect(card.getByText(candidate.candidate_catalog_entry_id)).toBeVisible();
await card.getByText("검수 payload 펼쳐 보기").click();
await expect(card.getByRole("heading", { name: "상황과 도전" })).toBeVisible();
await expect(card).not.toContainText("hidden_answer");
await expect(card).not.toContainText("raw_transcript");
const reason = card.getByLabel("콘텐츠 승인 사유");
const approveButton = card.getByRole("button", { name: "카탈로그 승인" });
await expect(approveButton).toBeDisabled();
await reason.fill(approvalBody.reason_code);
await expect(approveButton).toBeEnabled();
const approvalResponsePromise = page.waitForResponse((response) => {
if (response.request().method() !== "POST") return false;
const url = new URL(response.url());
if (!url.pathname.endsWith("/continuous-improvement/approvals")) return false;
const body = response.request().postDataJSON() as { target_id?: string };
return body.target_id === candidate.qualification_id;
});
await approveButton.focus();
await expect(approveButton).toBeFocused();
await approveButton.press("Enter");
const approvalResponse = await approvalResponsePromise;
await expectOk(approvalResponse);
const browserApprovalBody = approvalResponse.request().postDataJSON() as typeof approvalBody;
await expect(card.getByText("카탈로그 승인 원장 기록됨")).toBeVisible({ timeout: 15_000 });
await expectNoHorizontalOverflow(page);
const afterCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(afterCatalogResponse);
const afterCatalog = (await afterCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(afterCatalog);
const consumed = matching(afterCatalog.entries, candidate.qualification_id);
expect(consumed).toHaveLength(1);
expect(consumed[0]).toMatchObject({
catalog_entry_id: candidate.candidate_catalog_entry_id,
catalog_record_id: browserApprovalBody.effect_record_id,
status: "approved",
clinical_claim_allowed: false,
});
const approvalReplay = await page.request.post("/api/continuous-improvement/approvals", {
data: browserApprovalBody,
});
await expectOk(approvalReplay);
expect(approvalReplay.status()).toBe(201);
expect(await approvalReplay.json()).toMatchObject({ idempotent_replay: true });
const changedApproval = await page.request.post("/api/continuous-improvement/approvals", {
data: { ...browserApprovalBody, reason_code: `${browserApprovalBody.reason_code} 변경` },
});
expect(changedApproval.status(), await changedApproval.text()).toBe(409);
const finalViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(finalViewResponse);
const finalView = (await finalViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(finalView);
expect(
finalView.approvals.filter(
(item) =>
item.target_kind === "content_qualification" &&
item.target_id === candidate.qualification_id,
),
).toHaveLength(1);
expect(matching(finalView.catalog_entries, candidate.qualification_id)).toHaveLength(1);
await testInfo.attach("g8-approved-catalog", {
body: await page.screenshot({ fullPage: true }),
contentType: "image/png",
});
console.log(
`G8_LIVE_EVIDENCE ${JSON.stringify({
qualification_id: candidate.qualification_id,
catalog_entry_id: candidate.candidate_catalog_entry_id,
catalog_record_id: browserApprovalBody.effect_record_id,
approval_event_id: browserApprovalBody.approval_event_id,
agent_calls_executed: candidate.agent_calls_executed,
catalog_before: 0,
catalog_after: consumed.length,
final_approval_events: finalView.approvals.filter(
(item) =>
item.target_kind === "content_qualification" &&
item.target_id === candidate.qualification_id,
).length,
})}`,
);
});
test("approves an existing Claude-qualified pending payload without another model call @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(2 * 60_000);
const qualificationId = process.env.E2E_G8_EXISTING_QUALIFICATION_ID ?? "";
test.skip(!qualificationId, "Set E2E_G8_EXISTING_QUALIFICATION_ID to review an existing candidate.");
const healthResponse = await page.request.get("/api/health");
await expectOk(healthResponse);
expect((await healthResponse.json()) as { db: boolean; engine: boolean }).toMatchObject({
db: true,
engine: true,
});
const suffix = suffixFor(testInfo);
for (const role of ["learner", "teacher"] as const) {
await signIn(page, role, `${suffix}-existing-${role}`);
const blockedRead = await page.request.get("/api/continuous-improvement");
expect(blockedRead.status(), await blockedRead.text()).toBe(403);
const blockedCatalog = await page.request.get("/api/continuous-improvement/catalog");
expect(blockedCatalog.status(), await blockedCatalog.text()).toBe(403);
}
await signIn(page, "admin", `${suffix}-existing-admin`);
const beforeViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(beforeViewResponse);
const beforeView = (await beforeViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(beforeView);
const pending = beforeView.content_qualifications.find(
(item) => item.qualification_id === qualificationId,
);
expect(pending, "the requested existing qualification must still be pending").toBeTruthy();
expect(pending?.draft_payload).not.toBeNull();
expect(pending?.source_provenance_uris.length).toBeGreaterThan(0);
expect(pending?.source_provenance_uris.every((uri) => uri.startsWith("repo://"))).toBeTruthy();
expect(matching(beforeView.catalog_entries, qualificationId)).toHaveLength(0);
const beforeCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(beforeCatalogResponse);
const beforeCatalog = (await beforeCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(beforeCatalog);
expect(matching(beforeCatalog.entries, qualificationId)).toHaveLength(0);
await page.goto("/admin/continuous-improvement");
const card = page.locator(`[data-qualification-id="${qualificationId}"]`);
await expect(card).toBeVisible();
await card.getByText("검수 payload 펼쳐 보기").click();
await expect(card.getByRole("heading", { name: "상황과 도전" })).toBeVisible();
await expect(card).not.toContainText("hidden_answer");
await expect(card).not.toContainText("raw_transcript");
const reasonText = "기존 실제 Claude 합성 후보의 visible payload와 repo 근거를 관리자 브라우저에서 검수함";
const reason = card.getByLabel("콘텐츠 승인 사유");
const approveButton = card.getByRole("button", { name: "카탈로그 승인" });
await expect(approveButton).toBeDisabled();
await reason.fill(reasonText);
await expect(approveButton).toBeEnabled();
const approvalResponsePromise = page.waitForResponse((response) => {
if (response.request().method() !== "POST") return false;
const url = new URL(response.url());
if (!url.pathname.endsWith("/continuous-improvement/approvals")) return false;
const body = response.request().postDataJSON() as { target_id?: string };
return body.target_id === qualificationId;
});
await approveButton.focus();
await expect(approveButton).toBeFocused();
await approveButton.press("Enter");
const approvalResponse = await approvalResponsePromise;
await expectOk(approvalResponse);
const approvalBody = approvalResponse.request().postDataJSON() as {
submission_id: string;
approval_event_id: string;
effect_record_id: string;
target_kind: "content_qualification";
target_id: string;
decision: "approve_content";
reason_code: string;
evidence_refs: string[];
};
expect(approvalBody.reason_code).toBe(reasonText);
await expect(card.getByText("카탈로그 승인 원장 기록됨")).toBeVisible({ timeout: 15_000 });
await expectNoHorizontalOverflow(page);
const afterCatalogResponse = await page.request.get("/api/continuous-improvement/catalog");
await expectOk(afterCatalogResponse);
const afterCatalog = (await afterCatalogResponse.json()) as ApprovedCatalogResponse;
assertNoSensitivePayload(afterCatalog);
const consumed = matching(afterCatalog.entries, qualificationId);
expect(consumed).toHaveLength(1);
expect(consumed[0]).toMatchObject({
catalog_entry_id: pending?.catalog_entry_id,
catalog_record_id: approvalBody.effect_record_id,
status: "approved",
clinical_claim_allowed: false,
});
const approvalReplay = await page.request.post("/api/continuous-improvement/approvals", {
data: approvalBody,
});
await expectOk(approvalReplay);
expect(await approvalReplay.json()).toMatchObject({ idempotent_replay: true });
const changedApproval = await page.request.post("/api/continuous-improvement/approvals", {
data: { ...approvalBody, reason_code: `${reasonText} 변경` },
});
expect(changedApproval.status(), await changedApproval.text()).toBe(409);
const finalViewResponse = await page.request.get("/api/continuous-improvement");
await expectOk(finalViewResponse);
const finalView = (await finalViewResponse.json()) as ContinuousImprovementView;
assertNoSensitivePayload(finalView);
const approvalCount = finalView.approvals.filter(
(item) => item.target_kind === "content_qualification" && item.target_id === qualificationId,
).length;
expect(approvalCount).toBe(1);
expect(matching(finalView.catalog_entries, qualificationId)).toHaveLength(1);
await testInfo.attach("g8-existing-approved-catalog", {
body: await page.screenshot({ fullPage: true }),
contentType: "image/png",
});
console.log(
`G8_EXISTING_LIVE_EVIDENCE ${JSON.stringify({
qualification_id: qualificationId,
catalog_entry_id: pending?.catalog_entry_id,
catalog_record_id: approvalBody.effect_record_id,
approval_event_id: approvalBody.approval_event_id,
catalog_before: 0,
catalog_after: consumed.length,
final_approval_events: approvalCount,
new_model_calls: 0,
})}`,
);
});
});