평가 캐시와 개인정보 마스킹 보강
This commit is contained in:
parent
f0771db919
commit
6a81ec596c
11 changed files with 737 additions and 14 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue