대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -0,0 +1,182 @@
"""Regression tests for P1 PII masking before engine requests."""
from __future__ import annotations
import json
import unittest
from typing import Any
from unittest.mock import patch
from .engine_client import EngineClient, GenerateResponse
from .services import guardrail, orchestrator, persona, state_machine
RAW_PHONE = "010-1234-5678"
RAW_EMAIL = "test@example.com"
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]")
def _json_blob(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
def _message_blob(messages: object) -> str:
return "\n".join(message.content for message in messages) # type: ignore[attr-defined]
def _initial_state() -> state_machine.SessionState:
card = persona.P1
return state_machine.init_state(
params=card.openness_params(),
)
def _prepare_context() -> orchestrator.TurnContext:
return orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text=RAW_TEXT,
recent_turns=[
{
"speaker": "counselor",
"text": "Previous learner contact was already masked: [PHONE] [EMAIL] [RRN].",
}
],
)
def _assert_no_raw_pii(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for raw in RAW_VALUES:
test.assertNotIn(raw, blob)
def _assert_masked_pii_present(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for masked in MASK_VALUES:
test.assertIn(masked, blob)
class CaptureGenerateEngine:
def __init__(self) -> None:
self.request = None
self.payload: dict[str, Any] | None = None
self._payload_builder = EngineClient(base_url="http://engine.test")
async def generate(self, req):
self.request = req
self.payload = self._payload_builder._payload(req)
return GenerateResponse(
text="Masked engine reply.",
model="fake-model",
provider="fake-provider",
tokens_in=3,
tokens_out=4,
cost_usd=0.0,
)
class CaptureStreamEngine:
engine_mode = "fake-provider"
default_model = "fake-model"
def __init__(self) -> None:
self.request = None
self.payload: dict[str, Any] | None = None
self._payload_builder = EngineClient(base_url="http://engine.test")
async def stream(self, req):
self.request = req
self.payload = self._payload_builder._payload(req)
yield "event: token"
yield 'data: {"text":"Masked stream reply."}'
yield "event: done"
yield (
'data: {"provider":"fake-provider","model":"fake-model",'
'"tokens_in":5,"tokens_out":6,"cost_usd":0.0}'
)
class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.presidio_patch = patch.object(
guardrail,
"_try_load_presidio",
return_value=(None, None),
)
self.presidio_patch.start()
self.addCleanup(self.presidio_patch.stop)
def test_prepare_turn_keeps_raw_text_but_builds_masked_engine_messages(self) -> None:
ctx = _prepare_context()
self.assertEqual(ctx.learner_text_raw, RAW_TEXT)
for raw in RAW_VALUES:
self.assertIn(raw, ctx.learner_text_raw)
self.assertNotIn(raw, ctx.learner_text_masked)
self.assertNotIn(raw, _message_blob(ctx.messages))
for masked in MASK_VALUES:
self.assertIn(masked, ctx.learner_text_masked)
self.assertIn(masked, _message_blob(ctx.messages))
def test_prepare_turn_masks_raw_pii_from_context_inputs(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text="Current text has no identifiers.",
recall_summary=f"Recall mentioned {RAW_PHONE}.",
pinned_facts=[f"Pinned email {RAW_EMAIL}."],
recent_turns=[
{"speaker": "counselor", "text": f"Previous raw RRN {RAW_RRN}."},
],
)
blob = _message_blob(ctx.messages)
for raw in RAW_VALUES:
self.assertNotIn(raw, blob)
for masked in MASK_VALUES:
self.assertIn(masked, blob)
async def test_run_turn_generate_sends_only_masked_engine_payload(self) -> None:
ctx = _prepare_context()
engine = CaptureGenerateEngine()
await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
self.assertIsNotNone(engine.request)
self.assertIsNotNone(engine.payload)
_assert_no_raw_pii(self, engine.request.messages)
_assert_no_raw_pii(self, engine.payload)
_assert_masked_pii_present(self, engine.request.messages)
_assert_masked_pii_present(self, engine.payload)
self.assertEqual(ctx.learner_text_raw, RAW_TEXT)
async def test_run_turn_stream_sends_only_masked_engine_payload(self) -> None:
ctx = _prepare_context()
engine = CaptureStreamEngine()
events = [
event
async for event in orchestrator.run_turn_stream(ctx, engine) # type: ignore[arg-type]
]
self.assertEqual([event.event for event in events], ["token", "done"])
self.assertIsNotNone(engine.request)
self.assertIsNotNone(engine.payload)
_assert_no_raw_pii(self, engine.request.messages)
_assert_no_raw_pii(self, engine.payload)
_assert_masked_pii_present(self, engine.request.messages)
_assert_masked_pii_present(self, engine.payload)
self.assertEqual(ctx.learner_text_raw, RAW_TEXT)
if __name__ == "__main__":
unittest.main()