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
|
|
@ -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();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue