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

@ -3,6 +3,7 @@ import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import { existsSync } from "node:fs";
import http, { type IncomingMessage, type ServerResponse } from "node:http";
import net from "node:net";
import { completeAlliancePreCheckpoint } from "./support";
interface TestServer {
url: string;
@ -37,6 +38,7 @@ interface VoiceUiProbeMessage {
interface VoiceUiProbeState {
getUserMediaCalls: number;
webSocketConstructs: number;
recorderStarts: number;
recorderStops: number;
workletModuleLoads: number;
@ -51,8 +53,8 @@ interface VoiceUiProbeState {
closeEvents: number[];
}
// This fixture intentionally starts a DB-offline API with ALLOW_SEED_PERSONA_FALLBACK=true
// so the voice provider cascade can be exercised without a Postgres dependency.
// The provider servers are controlled, while session, consent, and alliance records use the
// configured development Postgres so production fail-closed persistence remains exercised.
const SEEDED_VOICE_PERSONA_CODE = "P1";
function readBody(req: IncomingMessage): Promise<Buffer> {
@ -222,9 +224,8 @@ async function startApi({
AUTH_DEV_LOGIN_ENABLED: "true",
AUTH_ALLOWED_EMAIL_DOMAINS: '["hs.ac.kr","twentyoz.kr"]',
ALLOW_SEED_PERSONA_FALLBACK: "true",
DATABASE_URL: "postgresql://user:pass@127.0.0.1:1/vignette",
DB_POOL_MIN_SIZE: "0",
DB_COMMAND_TIMEOUT: "1",
DB_POOL_MIN_SIZE: "1",
DB_COMMAND_TIMEOUT: "10",
ENGINE_URL: engineURL,
ENGINE_MODE: "claude_api",
ENGINE_TIMEOUT: "10",
@ -266,6 +267,67 @@ async function startApi({
};
}
async function completeControlledVoicePreflight(
page: Page,
apiBaseURL: string,
sessionId: string,
): Promise<void> {
const result = await page.evaluate(
async ({ apiBase, session }) => {
const alliance = await fetch(`${apiBase}/sessions/${session}/alliance-pulses`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
checkpoint: "pre",
scores: { goal: 0.5, task: 0.5, bond: 0.5 },
evidence_turn_ids: [],
}),
});
const allianceBody = await alliance.text();
if (!alliance.ok && alliance.status !== 409) {
return {
ok: false,
step: "alliance",
status: alliance.status,
body: allianceBody,
};
}
const consent = await fetch(
`${apiBase}/sessions/${session}/multimodal-alliance/consent`,
{
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
submission_id: crypto.randomUUID(),
consent_status: "granted",
retain_audio: false,
retain_derived_features: true,
transcript_retained: true,
retention_days: 1,
policy_version: "voice-provider-e2e-v1",
reason_code: "controlled_provider_test",
}),
},
);
const consentBody = await consent.text();
if (!consent.ok) {
return {
ok: false,
step: "consent",
status: consent.status,
body: consentBody,
};
}
return { ok: true };
},
{ apiBase: apiBaseURL, session: sessionId },
);
expect(result).toMatchObject({ ok: true });
}
async function waitForWeb(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise<void> {
const started = Date.now();
let lastError = "";
@ -384,7 +446,7 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
async function installSyntheticVoiceCapture(
page: Page,
options: { blockMediaElementPlayback?: boolean } = {},
options: { blockMediaElementPlayback?: boolean; getUserMediaDelayMs?: number } = {},
): Promise<void> {
await page.addInitScript((opts) => {
type ProbeMessage = {
@ -395,6 +457,7 @@ async function installSyntheticVoiceCapture(
};
type ProbeState = {
getUserMediaCalls: number;
webSocketConstructs: number;
recorderStarts: number;
recorderStops: number;
workletModuleLoads: number;
@ -411,6 +474,7 @@ async function installSyntheticVoiceCapture(
const w = window as Window & { __voiceUiProbe?: ProbeState };
const probe: ProbeState = {
getUserMediaCalls: 0,
webSocketConstructs: 0,
recorderStarts: 0,
recorderStops: 0,
workletModuleLoads: 0,
@ -445,6 +509,9 @@ async function installSyntheticVoiceCapture(
value: {
getUserMedia: async () => {
probe.getUserMediaCalls += 1;
if ((opts.getUserMediaDelayMs ?? 0) > 0) {
await new Promise((resolve) => window.setTimeout(resolve, opts.getUserMediaDelayMs));
}
return fakeStream;
},
},
@ -515,6 +582,7 @@ async function installSyntheticVoiceCapture(
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) super(url);
else super(url, protocols);
if (String(url).includes("/voice/ws")) probe.webSocketConstructs += 1;
this.addEventListener("message", (event) => {
if (typeof event.data === "string") {
probe.messages.push({ direction: "received", kind: "text", data: event.data });
@ -817,6 +885,7 @@ test.describe("voice cascade success path", () => {
expect(browserSetup, api.logs()).toMatchObject({ ok: true });
if (!browserSetup.ok) throw new Error(JSON.stringify(browserSetup));
const started = browserSetup.started;
await completeControlledVoicePreflight(page, api.baseURL, started.session_id);
const result = await probeVoiceCascade(page, api.baseURL, started.session_id);
const events = result.messages.map((message) => JSON.parse(message) as { type: string; [key: string]: unknown });
@ -875,7 +944,10 @@ test.describe("voice cascade success path", () => {
}
});
await installSyntheticVoiceCapture(page, { blockMediaElementPlayback: true });
await installSyntheticVoiceCapture(page, {
blockMediaElementPlayback: true,
getUserMediaDelayMs: 400,
});
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
@ -985,6 +1057,7 @@ test.describe("voice cascade success path", () => {
`pageText=${await page.locator("#root").innerText().catch(() => "")}`,
].join("\n\n"),
).toBeVisible({ timeout: 20_000 });
await completeAlliancePreCheckpoint(page);
const textInput = page.getByLabel("학습자 발화 입력");
const sendButton = page.getByRole("button", { name: "보내기", exact: true });
@ -1016,16 +1089,64 @@ test.describe("voice cascade success path", () => {
}, { apiBase: api.baseURL, personaCode: SEEDED_VOICE_PERSONA_CODE });
expect(freshVoiceSession, api.logs()).toMatchObject({ ok: true });
expect(typeof freshVoiceSession.body.session_id).toBe("string");
await completeControlledVoicePreflight(
page,
api.baseURL,
freshVoiceSession.body.session_id ?? "",
);
let uiConsentLedgerWrites = 0;
await page.route("**/sessions/*/multimodal-alliance/consent", async (route) => {
if (route.request().method() === "POST") {
uiConsentLedgerWrites += 1;
await new Promise((resolve) => setTimeout(resolve, 300));
}
await route.continue();
});
await page.goto(`${web.baseURL}/learn/session/${freshVoiceSession.body.session_id}`);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 20_000 });
const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled();
await page.keyboard.press("Space");
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await mic.click();
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(consentDialog).toBeVisible();
if (process.env.CAPTURE_G7_CONSENT === "1") {
const desktopViewport = page.viewportSize() ?? { width: 1440, height: 900 };
await page.screenshot({ path: "test-results/g7-voice-consent-desktop.png" });
await page.setViewportSize({ width: 390, height: 844 });
await expect(consentDialog).toBeVisible();
await page.screenshot({ path: "test-results/g7-voice-consent-mobile.png" });
await page.setViewportSize(desktopViewport);
}
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await consentDialog.getByRole("button", { name: "텍스트로 계속" }).click();
await expect(consentDialog).toBeHidden();
await expect(page.getByLabel("학습자 발화 입력")).toBeEnabled();
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await page.keyboard.press("Alt+m");
await expect(consentDialog).toBeVisible();
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
await expect(consentDialog.getByRole("button", { name: "동의 기록 중…" })).toBeVisible();
await expect.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls).toBe(0);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);
.toBe(1);
await expect
.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs, { timeout: 10_000 })
.toBe(1);
expect(uiConsentLedgerWrites).toBe(1);
await expect
.poll(async () => (await readVoiceUiProbe(page)).workletModuleLoads, { timeout: 10_000 })
.toBeGreaterThan(0);
@ -1136,6 +1257,42 @@ test.describe("voice cascade success path", () => {
expect.arrayContaining(["POST /v1/audio/transcriptions", "POST /v1/audio/speech"]),
);
expect(engine.requests()).toContain("POST /v1/generate");
const stalePermissionSession = await page.evaluate(async ({ apiBase, personaCode }) => {
const response = await fetch(`${apiBase}/sessions`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({ persona_code: personaCode, theory_mode: "humanistic" }),
});
return {
ok: response.ok,
status: response.status,
body: await response.json() as { session_id?: string },
};
}, { apiBase: api.baseURL, personaCode: SEEDED_VOICE_PERSONA_CODE });
expect(stalePermissionSession, api.logs()).toMatchObject({ ok: true });
const staleSessionId = stalePermissionSession.body.session_id ?? "";
await completeControlledVoicePreflight(page, api.baseURL, staleSessionId);
await page.goto(`${web.baseURL}/learn/session/${staleSessionId}`);
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 20_000 });
const beforeStaleAttempt = await readVoiceUiProbe(page);
await page.keyboard.press("Alt+m");
const staleConsentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
await expect(staleConsentDialog).toBeVisible();
await staleConsentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls)
.toBe(beforeStaleAttempt.getUserMediaCalls + 1);
await page.keyboard.press("p");
await expect(page.getByRole("button", { name: "이어가기" })).toBeVisible();
await expect
.poll(async () => (await readVoiceUiProbe(page)).trackStops, { timeout: 5_000 })
.toBe(beforeStaleAttempt.trackStops + 1);
await expect.poll(async () => (await readVoiceUiProbe(page)).webSocketConstructs).toBe(
beforeStaleAttempt.webSocketConstructs,
);
} finally {
await web.stop();
await api.stop();