diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index 2ca1b46..a7f0618 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -886,10 +886,16 @@ def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> Cr # 자살/자해 '수단·방법' 정보 패턴 (절대 출력 금지). 정서 표현은 허용. # 1.1 확장(2026-07-15): DBT 자해 페르소나 대비 자해 도구·방법 어휘 보강. # 과차단 주의 — "선을 긋다"(경계 설정) 같은 정상 상담 표현과 겹치지 않는 조합만 추가. +# 1.2 축소·확장(2026-09-29): "독"·"방법은" 부분문자열이 "고독"·"독립"·"독서"·"독특"· +# "다른 방법은"·"표현 방법은" 같은 정상 정서 표현을 오탐 차단하여 제거하고, +# 실제 수단 정보만 가리키는 구체 조합으로 대체. _MEANS_TERMS = [ "약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약", - "손목을", "칼로", "뛰어내리", "수면제 ", "독", "방법은", "어떻게 죽", + "손목을", "칼로", "뛰어내리", "수면제 ", "어떻게 죽", "커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지", + "독약", "독극물", "음독", "독을 먹", "독을 마시", "독을 타", + "죽는 방법", "죽을 방법", "죽는 법", "자살 방법", "자살하는 방법", + "자해 방법", "자해하는 방법", ] _DISPLAY_PLACEHOLDER_LABELS = { "COUNSELOR": "상담자", diff --git a/apps/api/app/test_guardrail_means_terms.py b/apps/api/app/test_guardrail_means_terms.py new file mode 100644 index 0000000..3083f53 --- /dev/null +++ b/apps/api/app/test_guardrail_means_terms.py @@ -0,0 +1,92 @@ +"""_MEANS_TERMS 부분문자열 오탐 회귀 테스트. + +"독"·"방법은" 같은 넓은 부분문자열이 "고독"·"독립"·"독서"·"독특"· +"다른 방법은"·"표현 방법은" 같은 정상 정서 표현을 오탐 차단하지 않는지, +그리고 실제 수단 정보(독약·죽는 방법 등)는 여전히 차단되는지 검증한다. +""" + +from __future__ import annotations + +import unittest + +from .services import guardrail + + +class GuardrailMeansTermsFalsePositiveTest(unittest.TestCase): + def test_normal_expressions_are_not_blocked(self) -> None: + texts = [ + "요즘 너무 고독해요.", + "독립하고 싶은데 겁이 나요.", + "독서도 손에 안 잡혀요.", + "제가 좀 독특한가 봐요.", + "다른 방법은 잘 모르겠어요.", + "제 마음을 표현 방법은 모르겠어요.", + ] + + for text in texts: + with self.subTest(text=text): + result = guardrail.sanitize_client_reply( + text, + ideation_stage=1, + turn_seq=2, + ) + + self.assertFalse(result.blocked, text) + self.assertFalse(result.needs_regeneration, text) + self.assertEqual(result.reasons, []) + + +class GuardrailMeansTermsNewEntriesTest(unittest.TestCase): + def test_new_means_terms_are_blocked(self) -> None: + cases = { + "독약": "독약을 구할 수 있을까요.", + "독극물": "독극물을 사서 마시고 싶어요.", + "음독": "음독을 하면 편해질까요.", + "독을 먹": "그냥 독을 먹으면 끝날 것 같아요.", + "독을 마시": "독을 마시는 상상을 해요.", + "독을 타": "물에 독을 타 볼까 생각했어요.", + "죽는 방법": "가장 고통 없이 죽는 방법이 뭘까요.", + "죽을 방법": "확실하게 죽을 방법을 찾고 있어요.", + "죽는 법": "편하게 죽는 법을 알고 싶어요.", + "자살 방법": "자살 방법을 검색해봤어요.", + "자살하는 방법": "자살하는 방법을 알려줄 수 있나요.", + "자해 방법": "자해 방법을 바꿔볼까 해요.", + "자해하는 방법": "덜 아프게 자해하는 방법이 있을까요.", + } + + for term, text in cases.items(): + with self.subTest(term=term): + result = guardrail.sanitize_client_reply( + text, + ideation_stage=1, + turn_seq=2, + ) + + self.assertTrue(result.blocked, text) + self.assertTrue(result.needs_regeneration, text) + self.assertIn(f"means_info:{term}", result.reasons) + + +class GuardrailMeansTermsStillBlockedTest(unittest.TestCase): + def test_existing_terms_remain_blocked(self) -> None: + cases = { + "번개탄": "번개탄을 피우고 싶다는 생각이 들어요.", + "목을 매": "방에서 목을 매는 상상을 해요.", + "뛰어내리": "옥상에서 뛰어내리고 싶어요.", + } + + for term, text in cases.items(): + with self.subTest(term=term): + result = guardrail.sanitize_client_reply( + text, + ideation_stage=1, + turn_seq=2, + ) + + self.assertTrue(result.blocked, text) + self.assertTrue(result.needs_regeneration, text) + self.assertIn(f"means_info:{term}", result.reasons) + + +if __name__ == "__main__": + unittest.main()