가상 내담자 출력 가드레일의 수단어 부분문자열 오탐 수정

"독"·"방법은"이 "고독"·"독립"·"다른 방법은" 같은 정서 표현을 차단해 재시도 없는 스트림 턴이 실패하던 문제를 구체 조합 13개로 좁혀 해결한다.
This commit is contained in:
Yun Chan 2026-09-30 13:22:55 +09:00
parent bda7ebb908
commit 36cb847e38
2 changed files with 99 additions and 1 deletions

View file

@ -886,10 +886,16 @@ def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> Cr
# 자살/자해 '수단·방법' 정보 패턴 (절대 출력 금지). 정서 표현은 허용. # 자살/자해 '수단·방법' 정보 패턴 (절대 출력 금지). 정서 표현은 허용.
# 1.1 확장(2026-07-15): DBT 자해 페르소나 대비 자해 도구·방법 어휘 보강. # 1.1 확장(2026-07-15): DBT 자해 페르소나 대비 자해 도구·방법 어휘 보강.
# 과차단 주의 — "선을 긋다"(경계 설정) 같은 정상 상담 표현과 겹치지 않는 조합만 추가. # 과차단 주의 — "선을 긋다"(경계 설정) 같은 정상 상담 표현과 겹치지 않는 조합만 추가.
# 1.2 축소·확장(2026-09-29): "독"·"방법은" 부분문자열이 "고독"·"독립"·"독서"·"독특"·
# "다른 방법은"·"표현 방법은" 같은 정상 정서 표현을 오탐 차단하여 제거하고,
# 실제 수단 정보만 가리키는 구체 조합으로 대체.
_MEANS_TERMS = [ _MEANS_TERMS = [
"약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약", "약을 얼마나", "몇 알", "몇 정", "투신", "목을 매", "목 매", "번개탄", "농약",
"손목을", "칼로", "뛰어내리", "수면제 ", "독", "방법은", "어떻게 죽", "손목을", "칼로", "뛰어내리", "수면제 ", "어떻게 죽",
"커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지", "커터", "면도날", "손목 긋", "깊게 그으", "라이터로 지",
"독약", "독극물", "음독", "독을 먹", "독을 마시", "독을 타",
"죽는 방법", "죽을 방법", "죽는 법", "자살 방법", "자살하는 방법",
"자해 방법", "자해하는 방법",
] ]
_DISPLAY_PLACEHOLDER_LABELS = { _DISPLAY_PLACEHOLDER_LABELS = {
"COUNSELOR": "상담자", "COUNSELOR": "상담자",

View file

@ -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()