PII 표시 토큰 누출 보정

This commit is contained in:
Yun Chan 2026-08-09 21:04:29 +09:00
parent 61a41d1f08
commit 7cece90e33
19 changed files with 284 additions and 197 deletions

View file

@ -247,6 +247,8 @@ async def _retrieve_live_coach_grounding(
) -> list[live_coach.LiveCoachGrounding]:
"""라이브 코치용 평가 근거 회수. 미가용 시 빈 리스트로 진행한다."""
learner_masked = guardrail.mask_pii(learner_text).text_masked
# LiveCoachRequest.client_reply는 브라우저가 보내는 제어 입력이므로 합성 출력이
# 아니다. 일반 입력용 PII 게이트를 유지해 문맥 없는 실제 이름도 차단한다.
client_masked = guardrail.mask_pii(client_reply or "").text_masked
query = " ".join(
part for part in [stage, theory_mode, learner_masked, client_masked] if part

View file

@ -41,6 +41,7 @@ from ..persona_repository import (
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import (
evaluator,
guardrail,
multimodal_alliance,
multimodal_alliance_store,
orchestrator,
@ -1512,7 +1513,7 @@ def _client_turn_text_for_speech(sess: InProcSession, turn_seq: int) -> str | No
match = visible_clients[index] if 0 <= index < len(visible_clients) else None
if match is not None:
text = (match.text_masked or match.text).strip()
return text or None
return guardrail.humanize_pii_placeholders(text) or None
return None

View file

@ -520,7 +520,9 @@ def build_fast_messages(ctx: "TurnContext", client_reply: str) -> list[EngineMes
"""fast-loop 평가 프롬프트(L0 역할 + 후보 라벨 + 이번 턴 맥락)."""
st = ctx.state_after or ctx.state_before
theory = _theory_mode(ctx)
client_reply_masked = guardrail.mask_pii(client_reply).text_masked
client_reply_masked = guardrail.mask_synthetic_generated_pii(
client_reply
).text_masked
recent = (
"\n".join(
f"{speaker_ko_label(t.get('speaker'))}: {t.get('text', '')}"

View file

@ -49,7 +49,7 @@ _KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}"
# 인명 마지막 글자로 사실상 쓰이지 않는 용언 활용/명사화 꼬리 글자. 문맥 단서가
# 없는 인명 패턴이 "유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은"
# 같은 일반 단어를 이름으로 오탐해 평가 문장을 훼손하는 것을 막는다(정밀도 가드).
_KOREAN_NAME_TAIL_GUARD = "(?<![했됐되된될것듯점음움됨함감])"
_KOREAN_NAME_TAIL_GUARD = "(?<![했됐되된될것듯점음움됨함감])"
_KOREAN_NAME_STOPWORDS = {
"연락",
"연락처",
@ -458,14 +458,18 @@ _DISPLAY_PLACEHOLDER_STREAM_TAIL = 16
def _adjust_particle(value: str, particle: str | None) -> str:
if particle is None:
return value
last = value[-1] if value else ""
codepoint = ord(last) - 0xAC00 if "" <= last <= "" else -1
has_batchim = codepoint >= 0 and codepoint % 28 != 0
if particle in ("", ""):
return value + ""
return value + ("" if has_batchim else "")
if particle in ("", ""):
return value + ""
return value + ("" if has_batchim else "")
if particle in ("", ""):
return value + ""
return value + ("" if has_batchim else "")
if particle in ("", ""):
return value + ""
return value + ("" if has_batchim else "")
return value + particle
@ -621,6 +625,7 @@ __all__ = [
"PiiEntitySpan",
"set_ko_pii_recognizer",
"mask_pii",
"mask_synthetic_generated_pii",
"CrisisKind",
"CrisisResult",
"classify_crisis",

View file

@ -221,7 +221,8 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
text = (
"유의점은 세 가지다. 정서 신호가 방치되는 것을 피하고, 회기를 "
"마무리했을 때 연결감과 작업동맹을 지키며, 감정을 표현함을 존중한다. "
"어지러움은 신체 신호로 다룬다."
"어지러움은 신체 신호로 다룬다. 뭘 할 수 있게 되는 건지 잘 "
"모르겠는데요."
)
masked = guardrail.mask_pii(text)

View file

@ -376,7 +376,10 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply="저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.",
client_reply=(
"저는 김서연 씨고 한신대학교 상담심리학과 학생이에요. "
"여기서 뭘 해야 하는지 잘 모르겠는데요."
),
safety_flagged=False,
state_after=ctx.state_after,
llm_provider="claude_cli",
@ -395,19 +398,24 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
response.client_reply,
"저는 김서연 씨고 한신대학교 상담심리학과 학생이에요.",
"저는 김서연 씨고 한신대학교 상담심리학과 학생이에요. "
"여기서 뭘 해야 하는지 잘 모르겠는데요.",
)
self.assertEqual(len(sess.turns), 2)
learner_turn, client_turn = sess.turns
self.assertIsNone(learner_turn.llm_provider)
self.assertEqual(
client_turn.text, "저는 김서연 씨고 한신대학교 상담심리학과 학생이에요."
client_turn.text,
"저는 김서연 씨고 한신대학교 상담심리학과 학생이에요. "
"여기서 뭘 해야 하는지 잘 모르겠는데요.",
)
self.assertNotIn("김서연", client_turn.text_masked)
self.assertNotIn("한신대학교", client_turn.text_masked)
self.assertNotIn("상담심리학과", client_turn.text_masked)
self.assertIn("[NAME]", client_turn.text_masked)
self.assertIn("[ORG]", client_turn.text_masked)
self.assertIn("잘 모르겠는데요", client_turn.text_masked)
self.assertNotIn("[NAME]는데요", client_turn.text_masked)
self.assertEqual(client_turn.llm_provider, "claude_cli")
self.assertEqual(client_turn.model, "gateway-default")
self.assertEqual(client_turn.tokens_in, 17)
@ -991,6 +999,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
latency_ms=12,
query_text="정리 humanistic 자살 위험 최근성 보호요인",
)
retrieve_grounding = AsyncMock(return_value=retrieval)
with (
patch.object(
@ -999,13 +1008,16 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
patch.object(
sessions.rag,
"retrieve_eval_grounding",
AsyncMock(return_value=retrieval),
retrieve_grounding,
),
patch.object(sessions.rag, "log_retrieval", AsyncMock()),
):
grounding = await sessions._retrieve_live_coach_grounding(
learner_text="자살 위험 최근성과 보호요인을 확인했어요.",
client_reply="그래도 오늘은 친구에게 연락할 수 있어요.",
client_reply=(
"박민수에게 연락할지 잘 모르겠는데요. 그래도 오늘은 "
"친구에게 연락할 수 있어요."
),
stage="정리",
theory_mode="humanistic",
)
@ -1017,6 +1029,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(source.source_type, "official_guideline_summary")
self.assertEqual(source.version, "2026-06-15")
self.assertEqual(source.citation, "허가된 공식 지침 요약")
query = retrieve_grounding.await_args.kwargs["query"]
self.assertNotIn("박민수", query)
self.assertIn("[NAME]에게", query)
self.assertIn("잘 모르겠는데요", query)
self.assertNotIn("[NAME]는데요", query)
async def test_live_coach_persistence_failure_is_not_swallowed(self) -> None:
principal = _principal()

View file

@ -240,7 +240,9 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
speaker="client",
stage="초기",
text="raw client reply",
text_masked="마스킹된 내담자 응답",
text_masked=(
"[NAME]은 [ORG]에서 상담받고 있고 [PHONE]는 [DATE]와 달라요."
),
),
TurnRecord(
turn_seq=3,
@ -255,7 +257,7 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(
voice_routes._client_turn_text_for_speech(session, 2),
"마스킹된 내담자 응답",
"그 이름은 그 기관에서 상담받고 있고 연락처는 그 날짜와 달라요.",
)
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 3))
self.assertIsNone(voice_routes._client_turn_text_for_speech(session, 99))

View file

@ -122,7 +122,10 @@ async def record_completed_turn(
context=f"{context_prefix} turn append",
)
if result.client_reply:
client_mask = guardrail.mask_pii(result.client_reply)
# result.client_reply는 출력 가드레일을 통과한 합성 내담자 발화다.
# 입력용의 광범위한 성씨 휴리스틱을 다시 적용하면 "모르겠는데요" 같은
# 일반 용언이 [NAME]으로 오탐되어 저장·재개 화면에 누출된다.
client_mask = guardrail.mask_synthetic_generated_pii(result.client_reply)
await append_completed_turn(
sess,
TurnRecord(

View file

@ -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(

View file

@ -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 transcriptreplystate: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();
});
});

View 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;
}

View file

@ -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),
})),

View file

@ -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) {