평가 캐시와 개인정보 마스킹 보강

This commit is contained in:
Yun Chan 2026-06-28 20:12:20 +09:00
parent f0771db919
commit 6a81ec596c
11 changed files with 737 additions and 14 deletions

View file

@ -7,6 +7,7 @@ import unittest
from typing import Any
from unittest.mock import patch
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .engine_client import EngineClient, GenerateResponse
from .services import guardrail, orchestrator, persona, state_machine
@ -17,6 +18,15 @@ RAW_RRN = "990101-1234567"
RAW_TEXT = f"My phone is {RAW_PHONE}, email {RAW_EMAIL}, and RRN {RAW_RRN}."
RAW_VALUES = (RAW_PHONE, RAW_EMAIL, RAW_RRN)
MASK_VALUES = ("[PHONE]", "[EMAIL]", "[RRN]")
RAW_KO_NAME = "김서연"
RAW_KO_ORG = "한신대학교"
RAW_KO_DEPT = "상담심리학과"
RAW_KO_TEXT = (
f"내담자 {RAW_KO_NAME}{RAW_KO_ORG} {RAW_KO_DEPT} 학생이고 "
"연락은 하지 말아 주세요."
)
RAW_KO_VALUES = (RAW_KO_NAME, RAW_KO_ORG, RAW_KO_DEPT)
MASK_KO_VALUES = ("[NAME]", "[ORG]")
def _json_blob(value: Any) -> str:
@ -62,6 +72,18 @@ def _assert_masked_pii_present(test: unittest.TestCase, value: object) -> None:
test.assertIn(masked, blob)
def _assert_no_raw_ko_pii(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for raw in RAW_KO_VALUES:
test.assertNotIn(raw, blob)
def _assert_masked_ko_pii_present(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for masked in MASK_KO_VALUES:
test.assertIn(masked, blob)
class CaptureGenerateEngine:
def __init__(self) -> None:
self.request = None
@ -101,6 +123,13 @@ class CaptureStreamEngine:
'"tokens_in":5,"tokens_out":6,"cost_usd":0.0}'
)
async def stream_packets(self, req):
decoder = EngineGatewaySseLineDecoder()
async for raw in self.stream(req):
packet = decoder.feed_line(raw)
if packet is not None:
yield packet
class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
@ -145,6 +174,47 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
for masked in MASK_VALUES:
self.assertIn(masked, blob)
def test_mask_pii_masks_korean_name_and_institution_context(self) -> None:
masked = guardrail.mask_pii(
f"이름: {RAW_KO_NAME}, 소속은 {RAW_KO_ORG} {RAW_KO_DEPT}입니다."
)
self.assertFalse(masked.used_presidio)
self.assertIn("NAME", masked.entities)
self.assertIn("ORG", masked.entities)
for raw in RAW_KO_VALUES:
self.assertNotIn(raw, masked.text_masked)
self.assertIn("[NAME]", masked.text_masked)
self.assertGreaterEqual(masked.text_masked.count("[ORG]"), 2)
def test_mask_pii_does_not_mask_common_korean_context_words_as_names(self) -> None:
masked = guardrail.mask_pii("학교 가는 게 힘들고 엄마랑 친구 이야기를 하면 불안해요.")
self.assertEqual(masked.text_masked, "학교 가는 게 힘들고 엄마랑 친구 이야기를 하면 불안해요.")
self.assertNotIn("NAME", masked.entities)
self.assertNotIn("ORG", masked.entities)
def test_prepare_turn_masks_korean_pii_from_engine_messages(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text=RAW_KO_TEXT,
recall_summary=f"지난 회기 요약에 {RAW_KO_NAME}{RAW_KO_ORG}가 남아 있었다.",
pinned_facts=[f"소속 {RAW_KO_DEPT}"],
recent_turns=[
{"speaker": "counselor", "text": f"{RAW_KO_NAME} 씨가 상담실에 왔다."},
],
)
blob = _message_blob(ctx.messages)
_assert_no_raw_ko_pii(self, blob)
_assert_masked_ko_pii_present(self, blob)
for raw in RAW_KO_VALUES:
self.assertIn(raw, ctx.learner_text_raw)
self.assertNotIn(raw, ctx.learner_text_masked)
def test_prepare_turn_threads_theory_mode_into_engine_messages(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="theory-session",
@ -220,6 +290,34 @@ class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
for key in ("messages", "prompt", "text"):
self.assertNotIn(key, audit_payloads[0])
async def test_run_turn_generate_sends_only_masked_korean_pii(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text=RAW_KO_TEXT,
)
engine = CaptureGenerateEngine()
audit_payloads: list[dict[str, Any]] = []
async def audit_hook(payload: dict[str, Any]) -> None:
audit_payloads.append(payload)
await orchestrator.run_turn_generate(
ctx,
engine, # type: ignore[arg-type]
audit_hook=audit_hook,
)
self.assertIsNotNone(engine.request)
self.assertIsNotNone(engine.payload)
_assert_no_raw_ko_pii(self, engine.request.messages)
_assert_no_raw_ko_pii(self, engine.payload)
_assert_masked_ko_pii_present(self, engine.request.messages)
_assert_masked_ko_pii_present(self, engine.payload)
_assert_no_raw_ko_pii(self, audit_payloads)
if __name__ == "__main__":
unittest.main()