diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index e7baa16..2ca1b46 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -469,19 +469,25 @@ _CRISIS_TERMS = [ "죽고 싶", "죽고싶", "자살 생각", "자살하고 싶", "자살을 생각", "자살 충동", "자살 시도", "목숨을 끊", "목숨을 버리", "사라지고 싶", "없어지고 싶", "자해하고 싶", "자해한 적", "자해를 했", "자해 충동", "자해 시도", - "자살 계획", "자해 계획", "자살하려고", "자해하려고", "죽으려고", + "자살 계획", "자해 계획", "자살하려고", "자살을 하려고", "자해하려고", + "자해를 하려고", "죽으려고", "자살하려", "죽으려", "자살할 계획", + "자해할 계획", "죽을 계획", "자살할 의도", "죽을 준비", + "제 자신을 해치려고", "삶을 끝내려고", "목숨을 끊을 계획", "스스로를 다치게", "스스로를 해칠 계획", "자살할지도", "끝내고 싶", "살기 싫", "살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵", - "안전을 유지할 수 없", + "안전을 유지할 수 없", "안전을 보장할 수 없", ] # 자살·자해 단어를 직접 쓰지 않더라도 본인의 현재 위험과 실제 도움 필요를 함께 # 밝히는 짧은 진술은 엔진 호출 전에 막아야 한다. 단일 '위험'/'도움' 키워드만으로는 # 상담자의 안전 질문까지 오탐할 수 있어 네 단서가 모두 함께 있을 때만 승격한다. _SELF_SUBJECT = ( r"(? bool: """양성 구문과 실제로 겹치는 부정·인용·타인·질문 문맥만 무효화한다.""" @@ -724,15 +782,9 @@ def _first_person_current_crisis(text: str) -> bool: ) -def crisis_signal_matches(text: str) -> list[str]: - """화자 판정 전의 위기 내용 단서를 반환한다. +def _crisis_signal_matches_clause(text: str) -> list[str]: + """한 절 안에서만 부정·인용·질문의 범위를 적용해 위기 단서를 찾는다.""" - 가상내담자 출력처럼 1인칭 표현이 정상인 경로에서는 이 함수로 내용 존재만 - 확인하고, 실제 수련생 위기 여부는 :func:`classify_crisis`가 별도로 판정한다. - """ - - if not text: - return [] matched: list[str] = [] for term in _CRISIS_TERMS: for term_match in re.finditer(re.escape(term), text): @@ -756,6 +808,34 @@ def crisis_signal_matches(text: str) -> list[str]: return matched +def crisis_signal_matches(text: str) -> list[str]: + """화자 판정 전의 위기 내용 단서를 반환한다. + + 가상내담자 출력처럼 1인칭 표현이 정상인 경로에서는 이 함수로 내용 존재만 + 확인하고, 실제 수련생 위기 여부는 :func:`classify_crisis`가 별도로 판정한다. + 대조 접속이나 문장 경계를 넘어서 부정·인용 문맥을 공유하지 않도록 절별로 + 판정하고, 같은 단서는 최초 한 번만 반환한다. + """ + + if not text: + return [] + matched: list[str] = [] + clauses = (part.strip() for part in _CRISIS_CLAUSE_SPLITTER.split(text)) + for clause in clauses: + if not clause: + continue + for signal in _crisis_signal_matches_clause(clause): + if signal not in matched: + matched.append(signal) + # 실제 도움 요청은 짧은 두 문장으로 끊어 말할 수 있으므로 위험 진술과 도움 + # 요청의 인접성만큼은 원문 전체에서 한 번 더 결합한다. + if _current_personal_danger_help(text) and _CURRENT_PERSONAL_DANGER_HELP not in matched: + matched.append(_CURRENT_PERSONAL_DANGER_HELP) + if _first_person_current_crisis(text) and _FIRST_PERSON_CURRENT_CRISIS not in matched: + matched.append(_FIRST_PERSON_CURRENT_CRISIS) + return matched + + def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> CrisisResult: """위기 분류. diff --git a/apps/api/app/test_client_reply_quality.py b/apps/api/app/test_client_reply_quality.py index 2bd3595..15be2b8 100644 --- a/apps/api/app/test_client_reply_quality.py +++ b/apps/api/app/test_client_reply_quality.py @@ -266,6 +266,50 @@ class ClientReplyQualityGateTest(unittest.TestCase): "자살할지도 모르겠어요.", "스스로를 해칠 계획이 있어요.", "제 안전을 유지할 수 없어요.", + "저는 지금 자살을 하려고 해요.", + "저는 지금 자해를 하려고 해요.", + "저는 지금 자살하려 해요.", + "저는 지금 죽으려 해요.", + "저는 오늘 밤 자살하려고 해요.", + "자살할 계획이에요.", + "죽을 계획이에요.", + "자살할 구체적인 계획이 있어요.", + "자살할 의도가 있어요.", + "자살 계획을 세웠어요.", + "자해 계획을 세웠어요.", + "제 목숨을 끊을 계획을 세웠어요.", + "제 자신을 해치려고 해요.", + "삶을 끝내려고 해요.", + "죽을 준비를 하고 있어요.", + "제 안전을 보장할 수 없어요. 도와주세요.", + "저는 지금 자살을 시도할지도 몰라요.", + "저는 지금 자해를 시도할지도 몰라요.", + "저는 지금 자해 생각이 반복돼요.", + "저는 지금 제 안전을 유지하기 어려워요. 도와주세요.", + "저는 지금 제 안전을 보장하기 어려워요. 도와주세요.", + "저는 지금 자살을 결심했어요.", + "저는 지금 죽기로 결심했어요.", + "저는 지금 자해하기로 했어요.", + "저는 오늘 밤 자살할 거예요.", + "저는 오늘 밤 자해할 거예요.", + "저는 지금 자살을 준비하고 있어요.", + "저는 지금 자살을 실행하려고 해요.", + "저는 지금 자살할래요.", + "저는 지금 죽을래요.", + "저는 지금 제 목숨을 끊을지도 몰라요.", + "저는 지금 자살 생각이 반복돼요.", + "저는 오늘 밤 제 목숨을 끊을 거예요.", + "저는 지금 정말로 자살하려고 해요.", + "저는 지금 실제로 자살하려고 해요.", + "저는 지금 곧 자살하려고 해요.", + "저는 오늘 저녁 자살하려고 해요.", + "저는 내일 자살하려고 해요.", + "친구는 자살할 것 같지 않지만 저는 지금 자살할지도 몰라요.", + "역할극에서는 죽으려고 한다고 말했지만 저는 지금 실제로 죽으려고 해요.", + "역할극을 끝냈고 저는 지금 실제로 죽으려고 해요.", + "역할극은 끝났고 실제로 저는 지금 죽으려고 해요.", + "역할극이 아니라 저는 지금 실제로 죽으려고 해요.", + "번역이 아니라 저는 지금 죽고 싶어요.", ) for signal in real_help_questions: with self.subTest(signal=signal): @@ -330,6 +374,42 @@ class ClientReplyQualityGateTest(unittest.TestCase): "저는 지금 죽어야 할 것 같지 않아요.", "저는 지금 살아갈 이유가 없는 건 아니에요.", "저는 지금 자해 충동이 있지는 않아요.", + "저는 지금 자살할 것 같진 않아요.", + "저는 지금 자해할 것 같지는 않아요.", + "저는 지금 살기 싫지는 않아요.", + "저는 지금 없어지고 싶지는 않아요.", + "저는 지금 죽어야 할 것 같지는 않아요.", + "저는 지금 살아갈 이유가 없지는 않아요.", + "저는 지금 자살 충동이 있지 않아요.", + "저는 지금 자살 계획이 있는 게 아니에요.", + "저는 지금 자살 생각이 있는 건 아니에요.", + "친구가 자살 계획이 있다고 말했어요.", + "배우자가 자살 생각이 있다고 말했어요.", + "제가 친구에게 자살 계획이 있는지 물어봤어요.", + "자살 생각이 있는지 확인할게요.", + "예전에 자살 계획이 있었지만 지금은 없어요.", + "저는 지금 제 안전을 유지할 수 없는 건 아니에요.", + "저는 지금 살고 싶지 않은 건 아니에요.", + "저는 지금 사라지고 싶지 않아요.", + "저는 지금 자살 시도를 하려는 건 아니에요.", + "저는 지금 자해 시도를 하려는 건 아니에요.", + "저는 지금 자살하려고 하지는 않아요.", + "저는 지금 죽으려고 하지는 않아요.", + "저는 지금 자살할 계획은 없어요.", + "저는 지금 죽을 계획이 없어요.", + "저는 지금 자살할 의도는 없어요.", + "저는 지금 죽을 준비를 하지 않았어요.", + "자살할 계획이 있는지 확인할게요.", + "죽을 계획이 있는지 물어볼게요.", + "제 안전을 보장할 수 없는지 확인할게요.", + "죽을 준비를 하고 있는지 묻겠습니다.", + "자살할 의도가 있는지 확인합니다.", + "자살 시도라는 문장을 번역하고 있어요.", + "자해 계획 설문을 설명하고 있어요.", + "아버지가 자살할 계획이 있다고 말했어요.", + "엄마가 죽을 계획이 있다고 말했어요.", + "동료가 자살할 의도가 있다고 말했어요.", + "그 사람이 자살 계획이 있다고 말했어요.", ) self.assertFalse(nonclinical_first_person.escalate) self.assertEqual(ordinary_wish.kind, guardrail.CrisisKind.NONE) diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py index d6cff9a..a3b2aaf 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -1553,6 +1553,42 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): "저는 지금 죽어야 할 것 같지 않아요.", "저는 지금 살아갈 이유가 없는 건 아니에요.", "저는 지금 자해 충동이 있지는 않아요.", + "저는 지금 자살할 것 같진 않아요.", + "저는 지금 자해할 것 같지는 않아요.", + "저는 지금 살기 싫지는 않아요.", + "저는 지금 없어지고 싶지는 않아요.", + "저는 지금 죽어야 할 것 같지는 않아요.", + "저는 지금 살아갈 이유가 없지는 않아요.", + "저는 지금 자살 충동이 있지 않아요.", + "저는 지금 자살 계획이 있는 게 아니에요.", + "저는 지금 자살 생각이 있는 건 아니에요.", + "친구가 자살 계획이 있다고 말했어요.", + "배우자가 자살 생각이 있다고 말했어요.", + "제가 친구에게 자살 계획이 있는지 물어봤어요.", + "자살 생각이 있는지 확인할게요.", + "예전에 자살 계획이 있었지만 지금은 없어요.", + "저는 지금 제 안전을 유지할 수 없는 건 아니에요.", + "저는 지금 살고 싶지 않은 건 아니에요.", + "저는 지금 사라지고 싶지 않아요.", + "저는 지금 자살 시도를 하려는 건 아니에요.", + "저는 지금 자해 시도를 하려는 건 아니에요.", + "저는 지금 자살하려고 하지는 않아요.", + "저는 지금 죽으려고 하지는 않아요.", + "저는 지금 자살할 계획은 없어요.", + "저는 지금 죽을 계획이 없어요.", + "저는 지금 자살할 의도는 없어요.", + "저는 지금 죽을 준비를 하지 않았어요.", + "자살할 계획이 있는지 확인할게요.", + "죽을 계획이 있는지 물어볼게요.", + "제 안전을 보장할 수 없는지 확인할게요.", + "죽을 준비를 하고 있는지 묻겠습니다.", + "자살할 의도가 있는지 확인합니다.", + "자살 시도라는 문장을 번역하고 있어요.", + "자해 계획 설문을 설명하고 있어요.", + "아버지가 자살할 계획이 있다고 말했어요.", + "엄마가 죽을 계획이 있다고 말했어요.", + "동료가 자살할 의도가 있다고 말했어요.", + "그 사람이 자살 계획이 있다고 말했어요.", ) for index, client_reply in enumerate(client_replies, start=1): @@ -1587,6 +1623,50 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): "자살할지도 모르겠어요.", "스스로를 해칠 계획이 있어요.", "제 안전을 유지할 수 없어요.", + "저는 지금 자살을 하려고 해요.", + "저는 지금 자해를 하려고 해요.", + "저는 지금 자살하려 해요.", + "저는 지금 죽으려 해요.", + "저는 오늘 밤 자살하려고 해요.", + "자살할 계획이에요.", + "죽을 계획이에요.", + "자살할 구체적인 계획이 있어요.", + "자살할 의도가 있어요.", + "자살 계획을 세웠어요.", + "자해 계획을 세웠어요.", + "제 목숨을 끊을 계획을 세웠어요.", + "제 자신을 해치려고 해요.", + "삶을 끝내려고 해요.", + "죽을 준비를 하고 있어요.", + "제 안전을 보장할 수 없어요. 도와주세요.", + "저는 지금 자살을 시도할지도 몰라요.", + "저는 지금 자해를 시도할지도 몰라요.", + "저는 지금 자해 생각이 반복돼요.", + "저는 지금 제 안전을 유지하기 어려워요. 도와주세요.", + "저는 지금 제 안전을 보장하기 어려워요. 도와주세요.", + "저는 지금 자살을 결심했어요.", + "저는 지금 죽기로 결심했어요.", + "저는 지금 자해하기로 했어요.", + "저는 오늘 밤 자살할 거예요.", + "저는 오늘 밤 자해할 거예요.", + "저는 지금 자살을 준비하고 있어요.", + "저는 지금 자살을 실행하려고 해요.", + "저는 지금 자살할래요.", + "저는 지금 죽을래요.", + "저는 지금 제 목숨을 끊을지도 몰라요.", + "저는 지금 자살 생각이 반복돼요.", + "저는 오늘 밤 제 목숨을 끊을 거예요.", + "저는 지금 정말로 자살하려고 해요.", + "저는 지금 실제로 자살하려고 해요.", + "저는 지금 곧 자살하려고 해요.", + "저는 오늘 저녁 자살하려고 해요.", + "저는 내일 자살하려고 해요.", + "친구는 자살할 것 같지 않지만 저는 지금 자살할지도 몰라요.", + "역할극에서는 죽으려고 한다고 말했지만 저는 지금 실제로 죽으려고 해요.", + "역할극을 끝냈고 저는 지금 실제로 죽으려고 해요.", + "역할극은 끝났고 실제로 저는 지금 죽으려고 해요.", + "역할극이 아니라 저는 지금 실제로 죽으려고 해요.", + "번역이 아니라 저는 지금 죽고 싶어요.", ) for index, client_reply in enumerate(client_replies, start=1):