158 lines
6.8 KiB
Python
158 lines
6.8 KiB
Python
"""Strict client reply quality gate regressions."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import unittest
|
|
|
|
from .paths import repo_path
|
|
from .services import guardrail
|
|
|
|
|
|
class ClientReplyQualityGateTest(unittest.TestCase):
|
|
def test_crisis_protocol_manifest_preserves_external_clinical_approval_gate(self) -> None:
|
|
manifest = json.loads(
|
|
repo_path("data", "clinical", "crisis-protocol-validation.json").read_text(
|
|
encoding="utf-8",
|
|
),
|
|
)
|
|
|
|
self.assertEqual(manifest["technical_status"], "verified")
|
|
self.assertEqual(manifest["clinical_status"], "pending_external_review")
|
|
required_approval_fields = {
|
|
"reviewer",
|
|
"organization",
|
|
"reviewed_at",
|
|
"decision",
|
|
}
|
|
approval = manifest["approval"]
|
|
self.assertTrue(required_approval_fields.issubset(approval))
|
|
for field in required_approval_fields:
|
|
self.assertIsNone(approval[field])
|
|
technical_gate_text = " ".join(manifest["technical_gates"])
|
|
for field in required_approval_fields:
|
|
self.assertIn(f"approval.{field}", technical_gate_text)
|
|
authorities = {source["authority"] for source in manifest["sources"]}
|
|
self.assertEqual(authorities, {"SAMHSA", "NIMH", "대한민국 보건복지부"})
|
|
self.assertGreaterEqual(len(manifest["review_sequence"]), 6)
|
|
self.assertGreaterEqual(len(manifest["technical_gates"]), 5)
|
|
|
|
def test_crisis_protocol_technical_gates(self) -> None:
|
|
counselor_inquiry = guardrail.classify_crisis(
|
|
"최근에 자살 생각이 든 적이 있나요?",
|
|
speaker_is_persona_context=True,
|
|
)
|
|
learner_crisis = guardrail.classify_crisis(
|
|
"저 지금 자살하고 싶어요. 도와주세요.",
|
|
speaker_is_persona_context=True,
|
|
)
|
|
client_signal = guardrail.sanitize_client_reply(
|
|
"가끔 사라지고 싶다는 생각이 들지만, 아직 누구에게도 말하지 못했어요.",
|
|
ideation_stage=2,
|
|
turn_seq=4,
|
|
)
|
|
means_detail = guardrail.sanitize_client_reply(
|
|
"면도날을 항상 가지고 다녀요.",
|
|
ideation_stage=3,
|
|
turn_seq=4,
|
|
)
|
|
over_cap = guardrail.sanitize_client_reply(
|
|
"요즘 버티기 어렵다는 생각이 자주 들어요.",
|
|
ideation_stage=4,
|
|
turn_seq=4,
|
|
)
|
|
|
|
self.assertEqual(counselor_inquiry.kind, guardrail.CrisisKind.PERSONA_PLAY)
|
|
self.assertFalse(counselor_inquiry.escalate)
|
|
self.assertEqual(learner_crisis.kind, guardrail.CrisisKind.LEARNER_REAL)
|
|
self.assertTrue(learner_crisis.escalate)
|
|
self.assertEqual(guardrail.crisis_resource()["number"], "109")
|
|
self.assertFalse(client_signal.needs_regeneration)
|
|
self.assertTrue(means_detail.needs_regeneration)
|
|
self.assertTrue(over_cap.blocked)
|
|
|
|
def test_blocks_self_harm_means_detail(self) -> None:
|
|
"""자해 수단·방법 상세는 차단(1.1 확장 — DBT 자해 페르소나 대비)."""
|
|
samples = [
|
|
"커터로 했어요. 그게 제일 쉬워서요.",
|
|
"면도날을 항상 가지고 다녀요.",
|
|
"어젯밤에도 손목 긋고 잠들었어요.",
|
|
]
|
|
for sample in samples:
|
|
with self.subTest(sample=sample):
|
|
result = guardrail.sanitize_client_reply(sample, ideation_stage=3, turn_seq=4)
|
|
self.assertTrue(result.needs_regeneration)
|
|
self.assertTrue(any(r.startswith("means_info:") for r in result.reasons))
|
|
|
|
def test_allows_boundary_setting_language(self) -> None:
|
|
"""'선을 긋다'(경계 설정) 같은 정상 상담 표현은 차단하지 않는다."""
|
|
result = guardrail.sanitize_client_reply(
|
|
"이제는 엄마랑 선을 긋고 제 생활을 지키고 싶어요.",
|
|
ideation_stage=1,
|
|
turn_seq=4,
|
|
)
|
|
self.assertFalse(result.needs_regeneration)
|
|
|
|
def test_blocks_role_meta_speech_variants(self) -> None:
|
|
samples = [
|
|
"내담자 역할로 응답하겠습니다. 엄마가 가보라고 해서요.",
|
|
"AI로서 답변드리면 저는 우울한 학생입니다.",
|
|
"상담자 입장에서 보면 제 핵심신념은 무가치감입니다.",
|
|
"제 핵심신념은 저는 쓸모없다는 것입니다.",
|
|
]
|
|
|
|
for sample in samples:
|
|
with self.subTest(sample=sample):
|
|
result = guardrail.sanitize_client_reply(sample, ideation_stage=1, turn_seq=2)
|
|
self.assertTrue(result.needs_regeneration)
|
|
self.assertIn("role_meta", result.reasons)
|
|
|
|
def test_blocks_repeated_greeting_after_opening_turn_only_when_greeting_starts_reply(self) -> None:
|
|
blocked = guardrail.sanitize_client_reply(
|
|
"안녕하세요. 처음 뵙겠습니다. 저는 서연이에요.",
|
|
ideation_stage=1,
|
|
turn_seq=3,
|
|
)
|
|
first_turn = guardrail.sanitize_client_reply(
|
|
"안녕하세요. 엄마가 가보라고 해서 왔어요.",
|
|
ideation_stage=1,
|
|
turn_seq=1,
|
|
)
|
|
quoted = guardrail.sanitize_client_reply(
|
|
"방금 선생님이 안녕하세요라고 말해서 더 어색했어요.",
|
|
ideation_stage=1,
|
|
turn_seq=3,
|
|
)
|
|
|
|
self.assertTrue(blocked.needs_regeneration)
|
|
self.assertIn("repeat_greeting_after_opening", blocked.reasons)
|
|
self.assertFalse(first_turn.needs_regeneration)
|
|
self.assertFalse(quoted.needs_regeneration)
|
|
|
|
def test_blocks_near_duplicate_previous_client_reply_but_allows_short_overlap(self) -> None:
|
|
previous = "몰라요. 엄마가 그냥 가보라고 해서 왔어요."
|
|
duplicate = guardrail.sanitize_client_reply(
|
|
"몰라요. 엄마가 그냥 가보라고 해서 왔어요.",
|
|
ideation_stage=1,
|
|
previous_client_reply=previous,
|
|
)
|
|
near_duplicate = guardrail.sanitize_client_reply(
|
|
"엄마가 그냥 가보라고 해서 왔어요. 몰라요.",
|
|
ideation_stage=1,
|
|
previous_client_reply=previous,
|
|
)
|
|
allowed = guardrail.sanitize_client_reply(
|
|
"엄마가 가보라고 한 건 맞는데, 지금은 좀 짜증나요.",
|
|
ideation_stage=1,
|
|
previous_client_reply=previous,
|
|
)
|
|
|
|
self.assertTrue(duplicate.needs_regeneration)
|
|
self.assertIn("duplicate_client_reply", duplicate.reasons)
|
|
self.assertTrue(near_duplicate.needs_regeneration)
|
|
self.assertIn("duplicate_client_reply", near_duplicate.reasons)
|
|
self.assertFalse(allowed.needs_regeneration)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|