diff --git a/apps/api/app/services/guardrail.py b/apps/api/app/services/guardrail.py index 300637a..e7baa16 100644 --- a/apps/api/app/services/guardrail.py +++ b/apps/api/app/services/guardrail.py @@ -469,8 +469,10 @@ _CRISIS_TERMS = [ "죽고 싶", "죽고싶", "자살 생각", "자살하고 싶", "자살을 생각", "자살 충동", "자살 시도", "목숨을 끊", "목숨을 버리", "사라지고 싶", "없어지고 싶", "자해하고 싶", "자해한 적", "자해를 했", "자해 충동", "자해 시도", - "스스로를 다치게", "끝내고 싶", "살기 싫", - "살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵", + "자살 계획", "자해 계획", "자살하려고", "자해하려고", "죽으려고", + "스스로를 다치게", "스스로를 해칠 계획", "자살할지도", "끝내고 싶", + "살기 싫", "살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵", + "안전을 유지할 수 없", ] # 자살·자해 단어를 직접 쓰지 않더라도 본인의 현재 위험과 실제 도움 필요를 함께 # 밝히는 짧은 진술은 엔진 호출 전에 막아야 한다. 단일 '위험'/'도움' 키워드만으로는 @@ -503,7 +505,7 @@ _PERSONAL_DANGER_PATTERNS = ( ), ) _PERSONAL_IMMEDIATE_HARM = ( - r"(?:안전하지\s*않|안전을?\s*지킬\s*수\s*없|" + r"(?:안전하지\s*않|안전을?\s*(?:지킬|유지할)\s*수\s*없|" r"(?:제|내)\s*안전을?\s*지킬\s*(?:자신이|수)?\s*없|" r"혼자\s*있으면\s*위험|" r"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*" @@ -527,11 +529,14 @@ _PERSONAL_IMMEDIATE_HARM_PATTERNS = ( ), ) _CURRENT_SUICIDE_OR_SELF_HARM = ( - r"(?:자살(?:하고)?\s*싶|자살할\s*것\s*같|죽고\s*싶|" + r"(?:자살(?:하고)?\s*싶|자살(?:하)?려고\s*(?:해요|합니다)|" + r"자살할\s*(?:것\s*같|지도\s*모르)|죽고\s*싶|" + r"죽으려고\s*(?:해요|합니다)|" r"죽어버리고\s*싶|살기\s*싫|살고\s*싶지\s*않|" - r"자해(?:하고)?\s*싶|자해할\s*것\s*같|" + r"자해(?:하고)?\s*싶|자해(?:하)?려고\s*(?:해요|합니다)|" + r"자해할\s*(?:것\s*같|지도\s*모르)|" r"(?:스스로를|저를|나를)\s*(?:다치게\s*하고|해치고)\s*싶|" - r"(?:제|내)\s*목숨을\s*끊고\s*싶|삶을\s*끝내고\s*싶|" + r"(?:제|내)\s*목숨을\s*(?:끊고\s*싶|끊으려고\s*(?:해요|합니다))|" r"끝내고\s*싶|없어지고\s*싶|죽어야\s*할\s*것\s*같|" r"살아갈\s*이유가\s*없|자해\s*충동이\s*있)" ) @@ -548,6 +553,12 @@ _FIRST_PERSON_CURRENT_CRISIS_PATTERNS = ( rf"{_SELF_SUBJECT}(?:이|은|도)?\s*(?:정말|진짜)\s*" rf"{_CURRENT_SUICIDE_OR_SELF_HARM}" ), + re.compile( + rf"{_CURRENT}\s*(?:당장\s*)?" + r"(?:자살(?:하)?려고\s*(?:해요|합니다)|" + r"죽으려고\s*(?:해요|합니다)|" + r"자해(?:하)?려고\s*(?:해요|합니다))" + ), ) _FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = ( re.compile( @@ -558,12 +569,27 @@ _FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = ( rf"당장\s*{_SELF_SUBJECT}(?:이|은|도)?\s*" rf"{_CURRENT_SUICIDE_OR_SELF_HARM}" ), + re.compile( + r"(? list[str]: matched.append(_CURRENT_PERSONAL_DANGER_HELP) if _first_person_current_crisis(text): matched.append(_FIRST_PERSON_CURRENT_CRISIS) + if any( + not _match_has_invalid_crisis_context(text, match) + for match in _EXPLICIT_HIGH_RISK_CUE.finditer(text) + ): + matched.append(_EXPLICIT_HIGH_RISK) return matched @@ -739,8 +776,14 @@ def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> Cr risk = min(5, 2 + len(matched)) # 거친 단계화(휴리스틱) explicit_real_signal = _CURRENT_PERSONAL_DANGER_HELP in matched explicit_current_crisis = _FIRST_PERSON_CURRENT_CRISIS in matched + explicit_high_risk = _EXPLICIT_HIGH_RISK in matched - if not speaker_is_persona_context or explicit_real_signal or explicit_current_crisis: + if ( + not speaker_is_persona_context + or explicit_real_signal + or explicit_current_crisis + or explicit_high_risk + ): # 실제 위기 가능 → 에스컬레이션(보수적) return CrisisResult( kind=CrisisKind.LEARNER_REAL, diff --git a/apps/api/app/test_client_reply_quality.py b/apps/api/app/test_client_reply_quality.py index 96636fd..2bd3595 100644 --- a/apps/api/app/test_client_reply_quality.py +++ b/apps/api/app/test_client_reply_quality.py @@ -254,6 +254,18 @@ class ClientReplyQualityGateTest(unittest.TestCase): "저는 지금 저를 해칠지도 몰라요. 도와주세요.", "제가 지금 저를 다치게 할까 봐 두려워요. 도움이 필요해요.", "저는 지금 혼자 있으면 위험해요. 도와주세요.", + "저는 지금 자살하려고 해요.", + "지금 당장 죽으려고 해요.", + "제 목숨을 끊으려고 해요.", + "자살 계획이 있어요.", + "자해 계획이 있어요.", + "자살 시도를 하려고 해요.", + "자해 시도를 하려고 해요.", + "자살 충동이 있어요.", + "자살 생각이 있어요.", + "자살할지도 모르겠어요.", + "스스로를 해칠 계획이 있어요.", + "제 안전을 유지할 수 없어요.", ) for signal in real_help_questions: with self.subTest(signal=signal): @@ -311,6 +323,13 @@ 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 b3162f9..d6cff9a 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -1546,6 +1546,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): "제가 지금 여쭤보는 건 자살 생각이 있는지예요.", "제가 지금 묻는 질문은 혹시 죽고 싶은 생각이 있는지예요.", "누구나 지금 자살하고 싶다고 말하는 것은 아니에요.", + "저는 지금 자살할 것 같지 않아요.", + "저는 지금 자해할 것 같지 않아요.", + "저는 지금 살기 싫지 않아요.", + "저는 지금 없어지고 싶지 않아요.", + "저는 지금 죽어야 할 것 같지 않아요.", + "저는 지금 살아갈 이유가 없는 건 아니에요.", + "저는 지금 자해 충동이 있지는 않아요.", ) for index, client_reply in enumerate(client_replies, start=1): @@ -1569,6 +1576,45 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase): engine.generate.assert_awaited_once() self.assertNotEqual(suggestion.focus, "risk") + async def test_live_coach_short_circuits_strong_crisis_variants(self) -> None: + client_replies = ( + "저는 지금 자살하려고 해요.", + "지금 당장 죽으려고 해요.", + "제 목숨을 끊으려고 해요.", + "자살 계획이 있어요.", + "자해 시도를 하려고 해요.", + "자살 충동이 있어요.", + "자살할지도 모르겠어요.", + "스스로를 해칠 계획이 있어요.", + "제 안전을 유지할 수 없어요.", + ) + + for index, client_reply in enumerate(client_replies, start=1): + with self.subTest(client_reply=client_reply): + item = live_coach.LiveCoachInput( + session_id=f"strong-crisis-variant-{index}", + turn_seq=2, + stage="exploration", + effective_openness=0.5, + theory_mode="humanistic", + persona_code="P1", + persona_name="서연", + learner_text="그 경험을 조금 더 들려줄 수 있을까요?", + client_reply=client_reply, + recent_turns=[], + ) + engine = SimpleNamespace( + generate=AsyncMock( + side_effect=AssertionError("strong crisis signal must bypass engine") + ) + ) + + suggestion = await live_coach.generate_live_coaching(item, engine=engine) + + engine.generate.assert_not_called() + self.assertEqual(suggestion.focus, "risk") + self.assertEqual(suggestion.status, "ready") + async def test_live_coach_prompt_uses_client_role_token_not_persona_name( self, ) -> None: