위기 표현 경계를 확장

This commit is contained in:
Yun Chan 2026-08-28 19:58:41 +09:00
parent 35a9d8802c
commit aeca42475b
3 changed files with 115 additions and 7 deletions

View file

@ -469,8 +469,10 @@ _CRISIS_TERMS = [
"죽고 싶", "죽고싶", "자살 생각", "자살하고 싶", "자살을 생각", "자살 충동", "죽고 싶", "죽고싶", "자살 생각", "자살하고 싶", "자살을 생각", "자살 충동",
"자살 시도", "목숨을 끊", "목숨을 버리", "사라지고 싶", "없어지고 싶", "자살 시도", "목숨을 끊", "목숨을 버리", "사라지고 싶", "없어지고 싶",
"자해하고 싶", "자해한 적", "자해를 했", "자해 충동", "자해 시도", "자해하고 싶", "자해한 적", "자해를 했", "자해 충동", "자해 시도",
"스스로를 다치게", "끝내고 싶", "살기 싫", "자살 계획", "자해 계획", "자살하려고", "자해하려고", "죽으려고",
"살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵", "스스로를 다치게", "스스로를 해칠 계획", "자살할지도", "끝내고 싶",
"살기 싫", "살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵",
"안전을 유지할 수 없",
] ]
# 자살·자해 단어를 직접 쓰지 않더라도 본인의 현재 위험과 실제 도움 필요를 함께 # 자살·자해 단어를 직접 쓰지 않더라도 본인의 현재 위험과 실제 도움 필요를 함께
# 밝히는 짧은 진술은 엔진 호출 전에 막아야 한다. 단일 '위험'/'도움' 키워드만으로는 # 밝히는 짧은 진술은 엔진 호출 전에 막아야 한다. 단일 '위험'/'도움' 키워드만으로는
@ -503,7 +505,7 @@ _PERSONAL_DANGER_PATTERNS = (
), ),
) )
_PERSONAL_IMMEDIATE_HARM = ( _PERSONAL_IMMEDIATE_HARM = (
r"(?:안전하지\s*않|안전을?\s*지킬\s*수\s*없|" r"(?:안전하지\s*않|안전을?\s*(?:지킬|유지할)\s*수\s*없|"
r"(?:제|내)\s*안전을?\s*지킬\s*(?:자신이|수)?\s*없|" r"(?:제|내)\s*안전을?\s*지킬\s*(?:자신이|수)?\s*없|"
r"혼자\s*있으면\s*위험|" r"혼자\s*있으면\s*위험|"
r"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*" r"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*"
@ -527,11 +529,14 @@ _PERSONAL_IMMEDIATE_HARM_PATTERNS = (
), ),
) )
_CURRENT_SUICIDE_OR_SELF_HARM = ( _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*싶지\s*않|"
r"자해(?:하고)?\s*싶|자해할\s*것\s*같|" r"자해(?:하고)?\s*싶|자해(?:하)?려고\s*(?:해요|합니다)|"
r"자해할\s*(?:것\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*것\s*같|"
r"살아갈\s*이유가\s*없|자해\s*충동이\s*있)" r"살아갈\s*이유가\s*없|자해\s*충동이\s*있)"
) )
@ -548,6 +553,12 @@ _FIRST_PERSON_CURRENT_CRISIS_PATTERNS = (
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*(?:정말|진짜)\s*" rf"{_SELF_SUBJECT}(?:이|은|도)?\s*(?:정말|진짜)\s*"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}" rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
), ),
re.compile(
rf"{_CURRENT}\s*(?:당장\s*)?"
r"(?:자살(?:하)?려고\s*(?:해요|합니다)|"
r"죽으려고\s*(?:해요|합니다)|"
r"자해(?:하)?려고\s*(?:해요|합니다))"
),
) )
_FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = ( _FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = (
re.compile( re.compile(
@ -558,12 +569,27 @@ _FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = (
rf"당장\s*{_SELF_SUBJECT}(?:이|은|도)?\s*" rf"당장\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}" rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
), ),
re.compile(
r"(?<![가-힣A-Za-z0-9])(?:제|내)\s*목숨을\s*"
r"(?:끊고\s*싶|끊으려고\s*(?:해요|합니다))"
),
) )
_REAL_HELP_CUE = re.compile( _REAL_HELP_CUE = re.compile(
r"(?:(?:실제로\s*)?도움(?:이|을)?\s*필요|도와(?:주|줄|주세요))" r"(?:(?:실제로\s*)?도움(?:이|을)?\s*필요|도와(?:주|줄|주세요))"
) )
_CURRENT_PERSONAL_DANGER_HELP = "first_person_current_danger_help" _CURRENT_PERSONAL_DANGER_HELP = "first_person_current_danger_help"
_FIRST_PERSON_CURRENT_CRISIS = "first_person_current_suicide_or_self_harm" _FIRST_PERSON_CURRENT_CRISIS = "first_person_current_suicide_or_self_harm"
_EXPLICIT_HIGH_RISK = "explicit_suicide_or_self_harm_risk"
_EXPLICIT_HIGH_RISK_CUE = re.compile(
r"(?<![가-힣A-Za-z0-9])(?:"
r"(?:자살|자해)\s*(?:계획|충동|생각)(?:이|가|은|는)?\s*(?:있|세웠)|"
r"(?:자살|자해)\s*시도(?:를|가|는)?\s*(?:하)?려고\s*(?:해요|합니다)|"
r"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*해칠\s*계획"
r"(?:이|가|은|는)?\s*(?:있|세웠)|"
r"(?:자살|자해)할지도\s*모르|"
r"(?:제|내)\s*안전을?\s*(?:지킬|유지할)\s*수\s*없"
r")"
)
_INDIRECT_SELF_ERASURE = "indirect_self_erasure" _INDIRECT_SELF_ERASURE = "indirect_self_erasure"
_INDIRECT_SELF_ERASURE_CUE = re.compile( _INDIRECT_SELF_ERASURE_CUE = re.compile(
r"(?<![가-힣A-Za-z0-9])(?:차라리\s*(?:없어졌으면|사라졌으면)|" r"(?<![가-힣A-Za-z0-9])(?:차라리\s*(?:없어졌으면|사라졌으면)|"
@ -607,6 +633,12 @@ _NEGATED_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?)\s*싶은\s*(?:생각|마음)" r"(?:자살(?:하고)?|죽고|자해(?:하고)?)\s*싶은\s*(?:생각|마음)"
r"(?:이|은)?\s*(?:전혀\s*)?(?:없|들지\s*않)|" r"(?:이|은)?\s*(?:전혀\s*)?(?:없|들지\s*않)|"
r"(?:자살\s*생각|자해\s*충동)(?:이|은)?\s*(?:전혀\s*)?(?:없|들지\s*않)|" r"(?:자살\s*생각|자해\s*충동)(?:이|은)?\s*(?:전혀\s*)?(?:없|들지\s*않)|"
r"(?:자살|자해)할\s*것\s*같지\s*않|살기\s*싫지\s*않|"
r"없어지고\s*싶지\s*않|죽어야\s*할\s*것\s*같지\s*않|"
r"살아갈\s*이유가\s*없는\s*(?:건|것은)\s*아니|"
r"자해\s*충동이\s*있지는\s*않|"
r"(?:자살|자해)\s*(?:계획|충동|생각|시도)(?:이|가|은|는)?\s*"
r"(?:전혀\s*)?(?:없|있지는\s*않|하지\s*않)|"
r"위험(?:한|할)\s*것\s*같지\s*않|도움(?:이|을)?\s*필요하지\s*않" r"위험(?:한|할)\s*것\s*같지\s*않|도움(?:이|을)?\s*필요하지\s*않"
) )
_RECOVERED_PAST_CRISIS = re.compile( _RECOVERED_PAST_CRISIS = re.compile(
@ -716,6 +748,11 @@ def crisis_signal_matches(text: str) -> list[str]:
matched.append(_CURRENT_PERSONAL_DANGER_HELP) matched.append(_CURRENT_PERSONAL_DANGER_HELP)
if _first_person_current_crisis(text): if _first_person_current_crisis(text):
matched.append(_FIRST_PERSON_CURRENT_CRISIS) 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 return matched
@ -739,8 +776,14 @@ def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> Cr
risk = min(5, 2 + len(matched)) # 거친 단계화(휴리스틱) risk = min(5, 2 + len(matched)) # 거친 단계화(휴리스틱)
explicit_real_signal = _CURRENT_PERSONAL_DANGER_HELP in matched explicit_real_signal = _CURRENT_PERSONAL_DANGER_HELP in matched
explicit_current_crisis = _FIRST_PERSON_CURRENT_CRISIS 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( return CrisisResult(
kind=CrisisKind.LEARNER_REAL, kind=CrisisKind.LEARNER_REAL,

View file

@ -254,6 +254,18 @@ class ClientReplyQualityGateTest(unittest.TestCase):
"저는 지금 저를 해칠지도 몰라요. 도와주세요.", "저는 지금 저를 해칠지도 몰라요. 도와주세요.",
"제가 지금 저를 다치게 할까 봐 두려워요. 도움이 필요해요.", "제가 지금 저를 다치게 할까 봐 두려워요. 도움이 필요해요.",
"저는 지금 혼자 있으면 위험해요. 도와주세요.", "저는 지금 혼자 있으면 위험해요. 도와주세요.",
"저는 지금 자살하려고 해요.",
"지금 당장 죽으려고 해요.",
"제 목숨을 끊으려고 해요.",
"자살 계획이 있어요.",
"자해 계획이 있어요.",
"자살 시도를 하려고 해요.",
"자해 시도를 하려고 해요.",
"자살 충동이 있어요.",
"자살 생각이 있어요.",
"자살할지도 모르겠어요.",
"스스로를 해칠 계획이 있어요.",
"제 안전을 유지할 수 없어요.",
) )
for signal in real_help_questions: for signal in real_help_questions:
with self.subTest(signal=signal): with self.subTest(signal=signal):
@ -311,6 +323,13 @@ class ClientReplyQualityGateTest(unittest.TestCase):
"저는 지금 자살 생각이 없어요.", "저는 지금 자살 생각이 없어요.",
"저는 지금 자해 충동이 없어요.", "저는 지금 자해 충동이 없어요.",
"저는 예전에 죽고 싶었지만 지금은 괜찮아요.", "저는 예전에 죽고 싶었지만 지금은 괜찮아요.",
"저는 지금 자살할 것 같지 않아요.",
"저는 지금 자해할 것 같지 않아요.",
"저는 지금 살기 싫지 않아요.",
"저는 지금 없어지고 싶지 않아요.",
"저는 지금 죽어야 할 것 같지 않아요.",
"저는 지금 살아갈 이유가 없는 건 아니에요.",
"저는 지금 자해 충동이 있지는 않아요.",
) )
self.assertFalse(nonclinical_first_person.escalate) self.assertFalse(nonclinical_first_person.escalate)
self.assertEqual(ordinary_wish.kind, guardrail.CrisisKind.NONE) self.assertEqual(ordinary_wish.kind, guardrail.CrisisKind.NONE)

View file

@ -1546,6 +1546,13 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
"제가 지금 여쭤보는 건 자살 생각이 있는지예요.", "제가 지금 여쭤보는 건 자살 생각이 있는지예요.",
"제가 지금 묻는 질문은 혹시 죽고 싶은 생각이 있는지예요.", "제가 지금 묻는 질문은 혹시 죽고 싶은 생각이 있는지예요.",
"누구나 지금 자살하고 싶다고 말하는 것은 아니에요.", "누구나 지금 자살하고 싶다고 말하는 것은 아니에요.",
"저는 지금 자살할 것 같지 않아요.",
"저는 지금 자해할 것 같지 않아요.",
"저는 지금 살기 싫지 않아요.",
"저는 지금 없어지고 싶지 않아요.",
"저는 지금 죽어야 할 것 같지 않아요.",
"저는 지금 살아갈 이유가 없는 건 아니에요.",
"저는 지금 자해 충동이 있지는 않아요.",
) )
for index, client_reply in enumerate(client_replies, start=1): for index, client_reply in enumerate(client_replies, start=1):
@ -1569,6 +1576,45 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
engine.generate.assert_awaited_once() engine.generate.assert_awaited_once()
self.assertNotEqual(suggestion.focus, "risk") 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( async def test_live_coach_prompt_uses_client_role_token_not_persona_name(
self, self,
) -> None: ) -> None: