PII 표시 토큰 누출 보정
This commit is contained in:
parent
61a41d1f08
commit
7cece90e33
19 changed files with 284 additions and 197 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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', '')}"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -221,7 +221,8 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
|
|||
text = (
|
||||
"유의점은 세 가지다. 정서 신호가 방치되는 것을 피하고, 회기를 "
|
||||
"마무리했을 때 연결감과 작업동맹을 지키며, 감정을 표현함을 존중한다. "
|
||||
"어지러움은 신체 신호로 다룬다."
|
||||
"어지러움은 신체 신호로 다룬다. 뭘 할 수 있게 되는 건지 잘 "
|
||||
"모르겠는데요."
|
||||
)
|
||||
|
||||
masked = guardrail.mask_pii(text)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue