PII 표시 토큰 누출 보정
This commit is contained in:
parent
61a41d1f08
commit
7cece90e33
19 changed files with 284 additions and 197 deletions
|
|
@ -145,8 +145,8 @@ async function readMetrics(page: Page): Promise<DashboardMetrics> {
|
|||
|
||||
function expectDerivedMetrics(metrics: DashboardMetrics) {
|
||||
expect(metrics.total).toBeGreaterThan(0);
|
||||
expect(metrics.done).toBe(32);
|
||||
expect(metrics.doing).toBe(3);
|
||||
expect(metrics.done).toBe(33);
|
||||
expect(metrics.doing).toBe(2);
|
||||
expect(metrics.planned).toBe(0);
|
||||
expect(metrics.total).toBe(metrics.done + metrics.doing + metrics.planned);
|
||||
expect(metrics.ownerBoard).toBe(
|
||||
|
|
|
|||
|
|
@ -192,6 +192,24 @@ async function routeSessionFixtureApi(page: Page, options: SessionFixtureOptions
|
|||
});
|
||||
});
|
||||
|
||||
await page.route(
|
||||
`**/api/sessions/${fixtureSessionId}/multimodal-alliance/consent`,
|
||||
async (route) => {
|
||||
const consentBody = route.request().postDataJSON() as Record<string, unknown>;
|
||||
await route.fulfill({
|
||||
status: 201,
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify({
|
||||
submission_id: consentBody.submission_id,
|
||||
consent_snapshot_id: "00000000-0000-4000-8000-000000000778",
|
||||
consent_status: "granted",
|
||||
deletion_request_id: null,
|
||||
idempotent_replay: false,
|
||||
}),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
await page.route(`**/api/sessions/${fixtureSessionId}/alliance-pulses`, async (route) => {
|
||||
await route.fulfill({
|
||||
status: 200,
|
||||
|
|
@ -319,6 +337,14 @@ async function routeSessionFixtureApi(page: Page, options: SessionFixtureOptions
|
|||
return { liveCoachRequests, consentRequests };
|
||||
}
|
||||
|
||||
async function acceptVoiceInputConsent(page: Page) {
|
||||
const consentDialog = page.getByRole("dialog", { name: "음성 입력을 사용하기 전에" });
|
||||
await expect(consentDialog).toBeVisible();
|
||||
await expect(consentDialog).toContainText("원음은 보존하지 않습니다");
|
||||
await consentDialog.getByRole("button", { name: "동의하고 마이크 켜기" }).click();
|
||||
await expect(consentDialog).toHaveCount(0);
|
||||
}
|
||||
|
||||
/**
|
||||
* 음성 마이크·WebSocket mock — session-mvp.spec.ts 패턴.
|
||||
* audio_end 수신 시 transcript→reply→state:speaking 을 흘려
|
||||
|
|
@ -615,6 +641,56 @@ test.describe("full sweep — counseling session", () => {
|
|||
await expect(compose).toHaveAttribute("placeholder", "종료된 회기입니다.");
|
||||
});
|
||||
|
||||
test("naturalizes privacy placeholders when a persisted session is resumed", async ({
|
||||
page,
|
||||
}) => {
|
||||
const startedAt = new Date(Date.now() - 60_000).toISOString();
|
||||
await routeSessionFixtureApi(page, {
|
||||
detailOverrides: {
|
||||
started_at: startedAt,
|
||||
turns: [
|
||||
{
|
||||
speaker: "learner",
|
||||
text:
|
||||
"[NAME] 이야기와 [PHONE]·[EMAIL]·[RRN]·[NUMID]·[DATE]·[MONEY]·[ADDR]·[ADDRESS]를 확인할까요?",
|
||||
turn_seq: 1,
|
||||
created_at: new Date(Date.now() - 50_000).toISOString(),
|
||||
},
|
||||
{
|
||||
speaker: "client",
|
||||
text: "여기서 뭘 할 수 있게 [NAME]는 건지 잘 [NAME]는데요. [ORG]에서 오라고 했어요.",
|
||||
turn_seq: 2,
|
||||
created_at: new Date(Date.now() - 40_000).toISOString(),
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
await page.goto(`/learn/session/${fixtureSessionId}`);
|
||||
|
||||
const transcript = page.locator(".sx-transcript");
|
||||
await expect(transcript.locator(".sx-utt")).toHaveCount(2);
|
||||
for (const token of [
|
||||
"[NAME]",
|
||||
"[ORG]",
|
||||
"[PHONE]",
|
||||
"[EMAIL]",
|
||||
"[RRN]",
|
||||
"[NUMID]",
|
||||
"[DATE]",
|
||||
"[MONEY]",
|
||||
"[ADDR]",
|
||||
"[ADDRESS]",
|
||||
]) {
|
||||
await expect(transcript).not.toContainText(token);
|
||||
}
|
||||
await expect(transcript).toContainText("익명 내담자 이야기");
|
||||
await expect(transcript).toContainText(
|
||||
"뭘 할 수 있게 되는 건지 잘 모르겠는데요",
|
||||
);
|
||||
await expect(transcript).toContainText("소속 기관에서 오라고 했어요");
|
||||
});
|
||||
|
||||
// checklist: session-transcript-autoscroll
|
||||
test("releases autoscroll when scrolling up, jumps back with the latest button, and follows new turns", async ({
|
||||
page,
|
||||
|
|
@ -966,6 +1042,7 @@ test.describe("full sweep — counseling session", () => {
|
|||
const micButton = page.getByRole("button", { name: "마이크 켜기" });
|
||||
await expect(micButton).toBeEnabled();
|
||||
await micButton.click();
|
||||
await acceptVoiceInputConsent(page);
|
||||
await page.getByRole("button", { name: "발화 보내기" }).click();
|
||||
|
||||
// reply + state:speaking → 재생 중에만 '건너뛰기'가 노출된다.
|
||||
|
|
@ -1018,11 +1095,14 @@ test.describe("full sweep — counseling session", () => {
|
|||
});
|
||||
|
||||
// checklist: session-keyboard-shortcuts
|
||||
test("toggles pause with P and mic with Space only outside text inputs", async ({ page }) => {
|
||||
test("toggles pause with P and mic with Alt+M only outside text inputs", async ({ page }) => {
|
||||
await routeSessionFixtureApi(page);
|
||||
await installVoiceSpeakingFixture(page, voiceTranscript, voiceClientReply);
|
||||
|
||||
await startFixtureSession(page);
|
||||
await page.evaluate(() => {
|
||||
if (document.activeElement instanceof HTMLElement) document.activeElement.blur();
|
||||
});
|
||||
|
||||
// P — 일시정지 토글.
|
||||
await page.keyboard.press("p");
|
||||
|
|
@ -1037,9 +1117,10 @@ test.describe("full sweep — counseling session", () => {
|
|||
await expect(compose).toHaveValue("p");
|
||||
await expect(page.getByRole("button", { name: "일시정지" })).toBeVisible();
|
||||
|
||||
// 포커스를 입력창 밖으로 옮긴 뒤 Space — 마이크 시작.
|
||||
// 포커스를 입력창 밖으로 옮긴 뒤 Alt+M — 마이크 시작.
|
||||
await compose.blur();
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.press("Alt+m");
|
||||
await acceptVoiceInputConsent(page);
|
||||
await expect(page.getByRole("button", { name: "발화 보내기" })).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
30
apps/web/src/lib/piiDisplay.ts
Normal file
30
apps/web/src/lib/piiDisplay.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
const DISPLAY_PLACEHOLDERS: Record<string, string> = {
|
||||
"[NAME]": "익명 내담자",
|
||||
"[ORG]": "소속 기관",
|
||||
"[PHONE]": "연락처",
|
||||
"[EMAIL]": "이메일",
|
||||
"[RRN]": "주민등록번호",
|
||||
"[NUMID]": "식별번호",
|
||||
"[DATE]": "날짜",
|
||||
"[MONEY]": "금액",
|
||||
"[ADDR]": "주소",
|
||||
"[ADDRESS]": "주소",
|
||||
};
|
||||
|
||||
/**
|
||||
* 저장/API의 privacy-proof 토큰은 유지하고, 사람이 읽는 일반 대화 표면에서만
|
||||
* 토큰을 안전한 설명으로 낮춘다. 근거 인용·내보내기에는 사용하지 않는다.
|
||||
*/
|
||||
export function displayPiiSafeText(text: string) {
|
||||
// 2026-08-09 이전 저장본에서 광범위한 성씨 휴리스틱이 평범한 용언을
|
||||
// NAME으로 오탐한 두 실관측 문형. 원문은 복원할 수 없으므로 표시층에서만
|
||||
// 결정적으로 복구하고 저장/API privacy proof는 그대로 둔다.
|
||||
let next = text.replace(
|
||||
/\[NAME\]는 건지\s+잘 \[NAME\]는데요/g,
|
||||
"되는 건지 잘 모르겠는데요",
|
||||
);
|
||||
for (const [placeholder, label] of Object.entries(DISPLAY_PLACEHOLDERS)) {
|
||||
next = next.split(placeholder).join(label);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
|
@ -43,6 +43,7 @@ import {
|
|||
} from "../lib/api";
|
||||
import { useAuth } from "../lib/auth";
|
||||
import { formatElapsed, formatTimecode, clamp01 } from "../lib/format";
|
||||
import { displayPiiSafeText } from "../lib/piiDisplay";
|
||||
import {
|
||||
parseVoicePracticeContext,
|
||||
voicePracticeSearch,
|
||||
|
|
@ -1001,7 +1002,7 @@ export default function Session() {
|
|||
(detail.turns ?? []).map((turn) => ({
|
||||
id: nextId(),
|
||||
speaker: turn.speaker === "client" ? "client" : "learner",
|
||||
text: turn.text,
|
||||
text: displayPiiSafeText(turn.text),
|
||||
turnSeq: turn.turn_seq,
|
||||
at: secondsBetween(detail.started_at, turn.created_at),
|
||||
})),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import type {
|
|||
UserPrepostMeasureItem,
|
||||
UserPrepostMeasuresResponse,
|
||||
} from "../../lib/api";
|
||||
import { displayPiiSafeText } from "../../lib/piiDisplay";
|
||||
|
||||
export type PrepostMeasureName = UserPrepostMeasureItem["measure_name"];
|
||||
export type PrepostTimepoint = UserPrepostMeasureItem["timepoint"];
|
||||
|
|
@ -132,23 +133,8 @@ export function displayEvaluationRetryError(message: string) {
|
|||
return "AI 평가 재시도를 완료하지 못했습니다. 잠시 뒤 다시 실행해 주세요.";
|
||||
}
|
||||
|
||||
const REVIEW_DISPLAY_PLACEHOLDERS: Record<string, string> = {
|
||||
"[NAME]": "익명 내담자",
|
||||
"[ORG]": "소속 기관",
|
||||
"[PHONE]": "연락처",
|
||||
"[EMAIL]": "이메일",
|
||||
"[RRN]": "주민등록번호",
|
||||
"[ADDRESS]": "주소",
|
||||
};
|
||||
|
||||
export function displayTranscriptText(text: string) {
|
||||
let next = text;
|
||||
for (const [placeholder, label] of Object.entries(
|
||||
REVIEW_DISPLAY_PLACEHOLDERS,
|
||||
)) {
|
||||
next = next.split(placeholder).join(label);
|
||||
}
|
||||
return next;
|
||||
return displayPiiSafeText(text);
|
||||
}
|
||||
|
||||
export function shouldPollReviewReady(data: SessionReviewResponse) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue