대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정
SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리
페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침
버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)
검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
parent
cb2aebd76c
commit
085460b5e0
327 changed files with 31226 additions and 1829 deletions
|
|
@ -16,12 +16,35 @@ interface SpawnedApi {
|
|||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface SpawnedWeb {
|
||||
baseURL: string;
|
||||
logs: () => string;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
interface VoiceProbe {
|
||||
code: number;
|
||||
messages: string[];
|
||||
binaryChunks: number;
|
||||
}
|
||||
|
||||
interface VoiceUiProbeMessage {
|
||||
direction: "sent" | "received";
|
||||
kind: "text" | "binary";
|
||||
data?: string;
|
||||
byteLength?: number;
|
||||
}
|
||||
|
||||
interface VoiceUiProbeState {
|
||||
getUserMediaCalls: number;
|
||||
recorderStarts: number;
|
||||
recorderStops: number;
|
||||
trackStops: number;
|
||||
audioPlays: number;
|
||||
messages: VoiceUiProbeMessage[];
|
||||
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.
|
||||
const SEEDED_VOICE_PERSONA_CODE = "P1";
|
||||
|
|
@ -137,9 +160,11 @@ async function waitForApi(baseURL: string, proc: ChildProcessWithoutNullStreams)
|
|||
async function startApi({
|
||||
engineURL,
|
||||
openAIBaseURL,
|
||||
frontendBaseURL = "http://localhost:5173",
|
||||
}: {
|
||||
engineURL: string;
|
||||
openAIBaseURL: string;
|
||||
frontendBaseURL?: string;
|
||||
}): Promise<SpawnedApi> {
|
||||
const port = await freePort();
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
|
|
@ -181,8 +206,8 @@ async function startApi({
|
|||
ENGINE_CONNECT_TIMEOUT: "2",
|
||||
OPENAI_API_KEY: "e2e-fake-key",
|
||||
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
|
||||
FRONTEND_BASE_URL: "http://localhost:5173",
|
||||
CORS_ORIGINS: '["http://localhost:5173"]',
|
||||
FRONTEND_BASE_URL: frontendBaseURL,
|
||||
CORS_ORIGINS: JSON.stringify([frontendBaseURL]),
|
||||
},
|
||||
windowsHide: true,
|
||||
},
|
||||
|
|
@ -215,6 +240,73 @@ async function startApi({
|
|||
};
|
||||
}
|
||||
|
||||
async function waitForWeb(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise<void> {
|
||||
const started = Date.now();
|
||||
let lastError = "";
|
||||
while (Date.now() - started < 30_000) {
|
||||
if (proc.exitCode !== null) {
|
||||
throw new Error(`Web exited early with code ${proc.exitCode}: ${lastError}`);
|
||||
}
|
||||
try {
|
||||
const response = await fetch(baseURL);
|
||||
if (response.ok) return;
|
||||
lastError = await response.text();
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
throw new Error(`Timed out waiting for web ${baseURL}: ${lastError}`);
|
||||
}
|
||||
|
||||
async function startWeb({
|
||||
apiBaseURL,
|
||||
port,
|
||||
}: {
|
||||
apiBaseURL: string;
|
||||
port: number;
|
||||
}): Promise<SpawnedWeb> {
|
||||
const baseURL = `http://127.0.0.1:${port}`;
|
||||
const proc = spawn(
|
||||
process.execPath,
|
||||
["node_modules/vite/bin/vite.js", "--host", "127.0.0.1", "--port", String(port)],
|
||||
{
|
||||
cwd: ".",
|
||||
env: {
|
||||
...process.env,
|
||||
VITE_API_BASE: apiBaseURL,
|
||||
},
|
||||
windowsHide: true,
|
||||
},
|
||||
);
|
||||
let logs = "";
|
||||
proc.stdout.on("data", (chunk) => {
|
||||
logs += String(chunk).slice(-4000);
|
||||
});
|
||||
proc.stderr.on("data", (chunk) => {
|
||||
logs += String(chunk).slice(-4000);
|
||||
});
|
||||
await waitForWeb(baseURL, proc).catch((err) => {
|
||||
proc.kill();
|
||||
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${logs}`);
|
||||
});
|
||||
return {
|
||||
baseURL,
|
||||
logs: () => logs,
|
||||
stop: async () => {
|
||||
if (proc.exitCode === null) proc.kill();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (proc.exitCode !== null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
proc.once("exit", () => resolve());
|
||||
setTimeout(resolve, 3000);
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: string): Promise<VoiceProbe> {
|
||||
return page.evaluate(
|
||||
({ apiBase, sid }) =>
|
||||
|
|
@ -264,6 +356,182 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
|
|||
);
|
||||
}
|
||||
|
||||
async function installSyntheticVoiceCapture(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
type ProbeMessage = {
|
||||
direction: "sent" | "received";
|
||||
kind: "text" | "binary";
|
||||
data?: string;
|
||||
byteLength?: number;
|
||||
};
|
||||
type ProbeState = {
|
||||
getUserMediaCalls: number;
|
||||
recorderStarts: number;
|
||||
recorderStops: number;
|
||||
trackStops: number;
|
||||
audioPlays: number;
|
||||
messages: ProbeMessage[];
|
||||
closeEvents: number[];
|
||||
};
|
||||
const w = window as Window & { __voiceUiProbe?: ProbeState };
|
||||
const probe: ProbeState = {
|
||||
getUserMediaCalls: 0,
|
||||
recorderStarts: 0,
|
||||
recorderStops: 0,
|
||||
trackStops: 0,
|
||||
audioPlays: 0,
|
||||
messages: [],
|
||||
closeEvents: [],
|
||||
};
|
||||
w.__voiceUiProbe = probe;
|
||||
|
||||
const fakeTrack = {
|
||||
kind: "audio",
|
||||
readyState: "live",
|
||||
stop() {
|
||||
probe.trackStops += 1;
|
||||
this.readyState = "ended";
|
||||
},
|
||||
};
|
||||
const fakeStream = {
|
||||
id: "synthetic-voice-ui-stream",
|
||||
active: true,
|
||||
getTracks: () => [fakeTrack],
|
||||
getAudioTracks: () => [fakeTrack],
|
||||
};
|
||||
Object.defineProperty(navigator, "mediaDevices", {
|
||||
configurable: true,
|
||||
value: {
|
||||
getUserMedia: async () => {
|
||||
probe.getUserMediaCalls += 1;
|
||||
return fakeStream;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
class FakeMediaRecorder extends EventTarget {
|
||||
static isTypeSupported() {
|
||||
return true;
|
||||
}
|
||||
|
||||
state = "inactive";
|
||||
mimeType: string;
|
||||
private timer: number | null = null;
|
||||
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
|
||||
onstop: ((event: Event) => void) | null = null;
|
||||
|
||||
constructor(_stream: unknown, options?: { mimeType?: string }) {
|
||||
super();
|
||||
this.mimeType = options?.mimeType ?? "audio/webm";
|
||||
}
|
||||
|
||||
start(timeslice?: number) {
|
||||
this.state = "recording";
|
||||
probe.recorderStarts += 1;
|
||||
const emit = () => {
|
||||
if (this.state !== "recording") return;
|
||||
const data = new Blob([new Uint8Array([1, 2, 3, 4, 5, 6])], {
|
||||
type: this.mimeType || "audio/webm",
|
||||
});
|
||||
const event = new Event("dataavailable") as Event & { data: Blob };
|
||||
Object.defineProperty(event, "data", { value: data });
|
||||
this.ondataavailable?.(event);
|
||||
this.dispatchEvent(event);
|
||||
};
|
||||
window.setTimeout(emit, 25);
|
||||
if (timeslice && timeslice > 0) {
|
||||
this.timer = window.setInterval(emit, timeslice);
|
||||
}
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.state === "inactive") return;
|
||||
this.state = "inactive";
|
||||
if (this.timer !== null) {
|
||||
window.clearInterval(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
probe.recorderStops += 1;
|
||||
const event = new Event("stop");
|
||||
this.onstop?.(event);
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
}
|
||||
Object.defineProperty(window, "MediaRecorder", {
|
||||
configurable: true,
|
||||
value: FakeMediaRecorder,
|
||||
});
|
||||
|
||||
const NativeWebSocket = window.WebSocket;
|
||||
const sizeOf = (data: unknown) => {
|
||||
if (typeof data === "string") return data.length;
|
||||
if (data instanceof Blob) return data.size;
|
||||
if (data instanceof ArrayBuffer) return data.byteLength;
|
||||
if (ArrayBuffer.isView(data)) return data.byteLength;
|
||||
return 0;
|
||||
};
|
||||
class ProbeWebSocket extends NativeWebSocket {
|
||||
constructor(url: string | URL, protocols?: string | string[]) {
|
||||
if (protocols === undefined) super(url);
|
||||
else super(url, protocols);
|
||||
this.addEventListener("message", (event) => {
|
||||
if (typeof event.data === "string") {
|
||||
probe.messages.push({ direction: "received", kind: "text", data: event.data });
|
||||
} else {
|
||||
probe.messages.push({
|
||||
direction: "received",
|
||||
kind: "binary",
|
||||
byteLength: sizeOf(event.data),
|
||||
});
|
||||
}
|
||||
});
|
||||
this.addEventListener("close", (event) => {
|
||||
probe.closeEvents.push(event.code);
|
||||
});
|
||||
}
|
||||
|
||||
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
|
||||
if (typeof data === "string") {
|
||||
probe.messages.push({ direction: "sent", kind: "text", data });
|
||||
} else {
|
||||
probe.messages.push({ direction: "sent", kind: "binary", byteLength: sizeOf(data) });
|
||||
}
|
||||
return super.send(data);
|
||||
}
|
||||
}
|
||||
for (const key of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"] as const) {
|
||||
Object.defineProperty(ProbeWebSocket, key, { value: NativeWebSocket[key] });
|
||||
}
|
||||
Object.defineProperty(window, "WebSocket", {
|
||||
configurable: true,
|
||||
value: ProbeWebSocket,
|
||||
});
|
||||
|
||||
HTMLMediaElement.prototype.play = function patchedPlay() {
|
||||
probe.audioPlays += 1;
|
||||
window.setTimeout(() => {
|
||||
this.dispatchEvent(new Event("ended"));
|
||||
}, 120);
|
||||
return Promise.resolve();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
|
||||
return page.evaluate(() => {
|
||||
const probe = (window as Window & { __voiceUiProbe?: VoiceUiProbeState }).__voiceUiProbe;
|
||||
if (!probe) throw new Error("voice UI probe was not installed");
|
||||
return probe;
|
||||
});
|
||||
}
|
||||
|
||||
async function parsedVoiceUiEvents(page: Page): Promise<Array<{ type?: string; [key: string]: unknown }>> {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
return probe.messages
|
||||
.filter((message) => message.direction === "received" && message.kind === "text" && message.data)
|
||||
.map((message) => JSON.parse(message.data ?? "{}") as { type?: string; [key: string]: unknown });
|
||||
}
|
||||
|
||||
test.describe("voice cascade success path", () => {
|
||||
test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({
|
||||
page,
|
||||
|
|
@ -357,4 +625,188 @@ test.describe("voice cascade success path", () => {
|
|||
await openai.close();
|
||||
}
|
||||
});
|
||||
|
||||
test("drives one voice turn through the Session mic UI with synthetic browser audio @single-run", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
test.setTimeout(90_000);
|
||||
|
||||
const diagnostics: string[] = [];
|
||||
page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
|
||||
});
|
||||
page.on("requestfailed", (request) => {
|
||||
diagnostics.push(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`);
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
const url = response.url();
|
||||
if (response.status() >= 400 && (url.includes("/sessions") || url.includes("/voice/ws"))) {
|
||||
diagnostics.push(`response: ${response.status()} ${url}`);
|
||||
}
|
||||
});
|
||||
|
||||
await installSyntheticVoiceCapture(page);
|
||||
|
||||
const openai = await startFakeOpenAI();
|
||||
const engine = await startFakeEngine();
|
||||
const webPort = await freePort();
|
||||
const webBaseURL = `http://127.0.0.1:${webPort}`;
|
||||
const api = await startApi({
|
||||
engineURL: engine.url,
|
||||
openAIBaseURL: openai.url,
|
||||
frontendBaseURL: webBaseURL,
|
||||
});
|
||||
const web = await startWeb({ apiBaseURL: api.baseURL, port: webPort });
|
||||
try {
|
||||
await page.route("**/personas", async (route) => {
|
||||
if (route.request().method() !== "GET") {
|
||||
await route.fallback();
|
||||
return;
|
||||
}
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
contentType: "application/json",
|
||||
headers: {
|
||||
"access-control-allow-origin": web.baseURL,
|
||||
"access-control-allow-credentials": "true",
|
||||
},
|
||||
body: JSON.stringify([
|
||||
{
|
||||
code: SEEDED_VOICE_PERSONA_CODE,
|
||||
display_name: "Voice UI fixture",
|
||||
difficulty: "hard",
|
||||
theory_target: ["humanistic"],
|
||||
demographics: { age_band: "teen" },
|
||||
presenting_summary: "Synthetic browser audio UI proof",
|
||||
voice_preset: "soft-young-fem",
|
||||
source: "database",
|
||||
degraded: false,
|
||||
},
|
||||
]),
|
||||
});
|
||||
});
|
||||
|
||||
await page.goto(`${web.baseURL}/login`, { waitUntil: "domcontentloaded" });
|
||||
const browserLogin = await page.evaluate(async ({ apiBase, workerIndex }) => {
|
||||
const login = await fetch(`${apiBase}/auth/dev-login`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
email: `voice-ui.${workerIndex}@hs.ac.kr`,
|
||||
role: "learner",
|
||||
display_name: "Voice UI",
|
||||
}),
|
||||
});
|
||||
const loginBody = await login.text();
|
||||
if (!login.ok) {
|
||||
return { ok: false, step: "login", status: login.status, body: loginBody };
|
||||
}
|
||||
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
|
||||
const meBody = await me.text();
|
||||
if (!me.ok) {
|
||||
return { ok: false, step: "me", status: me.status, body: meBody };
|
||||
}
|
||||
return { ok: true, me: JSON.parse(meBody) as unknown };
|
||||
}, {
|
||||
apiBase: api.baseURL,
|
||||
workerIndex: testInfo.workerIndex,
|
||||
});
|
||||
expect(browserLogin, api.logs()).toMatchObject({ ok: true });
|
||||
|
||||
await page.goto(`${web.baseURL}/learn/session/${SEEDED_VOICE_PERSONA_CODE}`);
|
||||
await expect(
|
||||
page.locator(".sx-prestart__actions button").first(),
|
||||
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
|
||||
).toBeVisible();
|
||||
await page.locator(".sx-prestart__actions button").first().click();
|
||||
await expect(
|
||||
page.locator(".sx-page.sx-page--active"),
|
||||
[
|
||||
...diagnostics,
|
||||
`apiLogs=${api.logs()}`,
|
||||
`pageText=${await page.locator("#root").innerText().catch(() => "")}`,
|
||||
].join("\n\n"),
|
||||
).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
const mic = page.locator(".sx-mic");
|
||||
await expect(mic).toBeEnabled();
|
||||
await mic.click();
|
||||
|
||||
await expect
|
||||
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(async () => (await readVoiceUiProbe(page)).recorderStarts, { timeout: 10_000 })
|
||||
.toBeGreaterThan(0);
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
return probe.messages.some(
|
||||
(message) =>
|
||||
message.direction === "sent" &&
|
||||
message.kind === "text" &&
|
||||
message.data?.includes('"audio_start"'),
|
||||
);
|
||||
}, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
return probe.messages.some(
|
||||
(message) => message.direction === "sent" && message.kind === "binary",
|
||||
);
|
||||
}, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
|
||||
await mic.click();
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
return probe.messages.some(
|
||||
(message) =>
|
||||
message.direction === "sent" &&
|
||||
message.kind === "text" &&
|
||||
message.data?.includes('"audio_end"'),
|
||||
);
|
||||
}, { timeout: 10_000 })
|
||||
.toBeTruthy();
|
||||
|
||||
await expect
|
||||
.poll(async () => {
|
||||
const events = await parsedVoiceUiEvents(page);
|
||||
return {
|
||||
transcript: events.some((event) => event.type === "transcript"),
|
||||
reply: events.some((event) => event.type === "reply"),
|
||||
ttsEnd: events.some((event) => event.type === "tts_end"),
|
||||
errors: events.filter((event) => event.type === "error" || event.type === "degraded"),
|
||||
};
|
||||
}, { timeout: 30_000 })
|
||||
.toEqual({ transcript: true, reply: true, ttsEnd: true, errors: [] });
|
||||
|
||||
const events = await parsedVoiceUiEvents(page);
|
||||
const transcript = events.find((event) => event.type === "transcript")?.text;
|
||||
const reply = events.find((event) => event.type === "reply")?.text;
|
||||
expect(typeof transcript).toBe("string");
|
||||
expect(typeof reply).toBe("string");
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: String(transcript) })).toBeVisible();
|
||||
await expect(page.locator(".sx-utt").filter({ hasText: String(reply) })).toBeVisible();
|
||||
|
||||
const probe = await readVoiceUiProbe(page);
|
||||
expect(probe.audioPlays).toBeGreaterThan(0);
|
||||
expect(probe.messages.some((message) => message.direction === "received" && message.kind === "binary")).toBe(
|
||||
true,
|
||||
);
|
||||
expect(openai.requests()).toEqual(
|
||||
expect.arrayContaining(["POST /v1/audio/transcriptions", "POST /v1/audio/speech"]),
|
||||
);
|
||||
expect(engine.requests()).toContain("POST /v1/generate");
|
||||
} finally {
|
||||
await web.stop();
|
||||
await api.stop();
|
||||
await engine.close();
|
||||
await openai.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue