위기 신호 기술 검증을 보강

This commit is contained in:
Yun Chan 2026-08-28 19:55:04 +09:00
parent 6988280b30
commit 35a9d8802c
5 changed files with 1164 additions and 14 deletions

View file

@ -466,11 +466,257 @@ class CrisisResult:
# 위기 표현 키워드(한국어 우선). TODO: JMIR 한국어 벤치 few-shot 분류기로 교체(R8).
_CRISIS_TERMS = [
"죽고 싶", "죽고싶", "자살", "목숨", "사라지고 싶", "없어지고 싶",
"자해", "끝내고 싶", "살기 싫", "살아서 뭐", "죽어야",
"죽고 싶", "죽고싶", "자살 생각", "자살하고 싶", "자살을 생각", "자살 충동",
"자살 시도", "목숨을 끊", "목숨을 버리", "사라지고 싶", "없어지고 싶",
"자해하고 싶", "자해한 적", "자해를 했", "자해 충동", "자해 시도",
"스스로를 다치게", "끝내고 싶", "살기 싫",
"살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵",
]
# 실제 위기로 가중되는 1인칭 현재 단서(수련생 본인 신호일 가능성)
_FIRST_PERSON_NOW = ["지금 나", "나 진짜", "제가 지금", "저 지금", "real", "도와주세요"]
# 자살·자해 단어를 직접 쓰지 않더라도 본인의 현재 위험과 실제 도움 필요를 함께
# 밝히는 짧은 진술은 엔진 호출 전에 막아야 한다. 단일 '위험'/'도움' 키워드만으로는
# 상담자의 안전 질문까지 오탐할 수 있어 네 단서가 모두 함께 있을 때만 승격한다.
_SELF_SUBJECT = (
r"(?<![가-힣A-Za-z0-9])"
r"(?:저\s*(?:자신|스스로)|나\s*(?:자신|스스로)|저한테|나한테|제가|저는|저|내가|나는|나)"
)
_CURRENT = r"(?:지금|현재|오늘)(?:은|도)?"
_DANGER = (
r"위험(?:한\s*(?:것\s*같|상태)|할\s*것\s*같|하다고\s*(?:느끼|느껴)|"
r"하(?:고|며)|해(?:요|서)?)"
)
_PERSONAL_DANGER_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}\s*"
rf"(?:정말\s*|실제로\s*|너무\s*)?{_DANGER}"
),
re.compile(
rf"{_CURRENT}\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"(?:정말\s*|실제로\s*|너무\s*)?{_DANGER}"
),
re.compile(
rf"(?:제|내)\s*(?:안전|신변|생명|목숨)(?:을|이|은)?\s*{_CURRENT}\s*"
rf"(?:스스로\s*)?(?:지키기\s*어렵|{_DANGER})"
),
re.compile(
rf"{_CURRENT}\s*(?:제|내)\s*(?:안전|신변|생명|목숨)(?:을|이|은)?\s*"
rf"(?:스스로\s*)?(?:지키기\s*어렵|{_DANGER})"
),
)
_PERSONAL_IMMEDIATE_HARM = (
r"(?:안전하지\s*않|안전을?\s*지킬\s*수\s*없|"
r"(?:제|내)\s*안전을?\s*지킬\s*(?:자신이|수)?\s*없|"
r"혼자\s*있으면\s*위험|"
r"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*"
r"(?:해칠까\s*봐|해칠\s*것\s*같|해칠지도\s*몰라|"
r"다치게\s*할까\s*봐|다치게\s*할\s*것\s*같))"
)
_PERSONAL_IMMEDIATE_HARM_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}\s*(?:당장\s*)?"
rf"{_PERSONAL_IMMEDIATE_HARM}"
),
re.compile(
rf"{_CURRENT}\s*{_SELF_SUBJECT}(?:이|은|도)?\s*(?:당장\s*)?"
rf"{_PERSONAL_IMMEDIATE_HARM}"
),
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}.{{0,45}}"
rf"(?:스스로를|저를|나를|제\s*자신을|내\s*자신을)\s*"
rf"(?:해칠\s*것\s*같|해칠지도\s*몰라|"
rf"다치게\s*할까\s*봐|다치게\s*할\s*것\s*같)"
),
)
_CURRENT_SUICIDE_OR_SELF_HARM = (
r"(?:자살(?:하고)?\s*싶|자살할\s*것\s*같|죽고\s*싶|"
r"죽어버리고\s*싶|살기\s*싫|살고\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*있)"
)
_FIRST_PERSON_CURRENT_CRISIS_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT}\s*"
rf"(?:정말\s*|진짜\s*)?{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_CURRENT}\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"(?:정말\s*|진짜\s*)?{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*(?:정말|진짜)\s*"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
)
_FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = (
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*(?:당장\s*)?"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"당장\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
)
_REAL_HELP_CUE = re.compile(
r"(?:(?:실제로\s*)?도움(?:이|을)?\s*필요|도와(?:주|줄|주세요))"
)
_CURRENT_PERSONAL_DANGER_HELP = "first_person_current_danger_help"
_FIRST_PERSON_CURRENT_CRISIS = "first_person_current_suicide_or_self_harm"
_INDIRECT_SELF_ERASURE = "indirect_self_erasure"
_INDIRECT_SELF_ERASURE_CUE = re.compile(
r"(?<![가-힣A-Za-z0-9])(?:차라리\s*(?:없어졌으면|사라졌으면)|"
r"(?:내가|제가|저\s*자신이|나\s*자신이)\s*(?:없어졌으면|사라졌으면))"
)
_REPORTED_OR_QUOTED_QUESTION = re.compile(
r"(?:내담자|상대|학생)(?:가|은|는|에게)?.{0,180}(?:라고|다고|냐고|는지)"
r".{0,40}(?:말하|묻|물어|확인|진술|표현)|"
r"[\"'“‘].{1,180}[\"'”’]\s*(?:라고|다고|냐고|는지)?.{0,60}"
r"(?:예시|읽|말하|가정|연습|문장|번역|질문|확인|묻|물어)"
)
_REPORTED_CRISIS_HELPING_OTHER = re.compile(
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r".{0,35}(?:다고|라고)\s*(?:말한|한)\s*"
r"(?:친구|내담자|학생|사람|가족|동생).{0,45}(?:돕|도와|지원)"
)
_CRISIS_META_QUESTION = re.compile(
r"(?:죽고\s*싶(?:은\s*생각이\s*있는|으신|은)?지|"
r"자살(?:하고)?\s*싶(?:으신|은)?지|자해(?:하고)?\s*싶(?:으신|은)?지)"
r".{0,50}(?:묻|물어|여쭙|여쭤|질문|확인|연습)"
)
_CRISIS_META_QUESTION_PREFIX = re.compile(
r"(?:묻|물어|여쭙|여쭤|질문|확인).{0,55}"
r"(?:자살\s*생각|죽고\s*싶(?:은)?\s*생각|자해\s*충동)"
r".{0,30}(?:있는지|있나|유무)"
)
_OTHER_PERSON_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?)\s*싶(?:은|어\s*하는|다는)\s*"
r"(?:친구|내담자|학생|사람|가족|동생)"
)
_CRISIS_META_OR_ROLEPLAY = re.compile(
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r"(?:(?:다고|냐고|은지|는지|다는).{0,60}"
r"(?:가정|역할극|연습|예시|읽|번역|문장|말하|묻|물어|질문|확인)|"
r"으면.{0,60}(?:설명|안내|교육|전화))"
)
_NEGATED_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?|끝내고)\s*"
r"싶(?:지(?:는)?\s*않|진\s*않|다는\s*뜻은\s*아니|"
r"\s*(?:건|것은)?\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(
r"(?:예전|과거|아까)(?:에는?|는)?\s*"
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r"(?:었지만|었으나|었는데).{0,45}(?:지금|현재)(?:은|는)?\s*"
r"(?:괜찮|안전|그렇지\s*않|생각이\s*없)"
)
_INVALID_REAL_CRISIS_CONTEXTS = (
_REPORTED_OR_QUOTED_QUESTION,
_REPORTED_CRISIS_HELPING_OTHER,
_CRISIS_META_QUESTION,
_CRISIS_META_QUESTION_PREFIX,
_OTHER_PERSON_CRISIS,
_CRISIS_META_OR_ROLEPLAY,
_NEGATED_CRISIS,
_RECOVERED_PAST_CRISIS,
)
def _match_has_invalid_crisis_context(text: str, match: re.Match[str]) -> bool:
"""양성 구문과 실제로 겹치는 부정·인용·타인·질문 문맥만 무효화한다."""
for pattern in _INVALID_REAL_CRISIS_CONTEXTS:
for context in pattern.finditer(text):
if match.start() < context.end() and context.start() < match.end():
return True
return False
def _current_personal_danger_help(text: str) -> bool:
"""현재의 개인적 위험과 실제 도움 필요를 함께 밝힌 짧은 진술인지 확인한다."""
if len(text) > 240:
return False
danger_matches = [
match
for pattern in (*_PERSONAL_DANGER_PATTERNS, *_PERSONAL_IMMEDIATE_HARM_PATTERNS)
for match in pattern.finditer(text)
if not _match_has_invalid_crisis_context(text, match)
]
help_matches = [
match
for match in _REAL_HELP_CUE.finditer(text)
if not _match_has_invalid_crisis_context(text, match)
]
return any(
abs(danger.start() - help_match.start()) <= 160
for danger in danger_matches
for help_match in help_matches
)
def _first_person_current_crisis(text: str) -> bool:
if len(text) > 240:
return False
if any(
not _match_has_invalid_crisis_context(text, match)
for pattern in (
*_FIRST_PERSON_CURRENT_CRISIS_PATTERNS,
*_FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS,
)
for match in pattern.finditer(text)
):
return True
direct_matches = [
match
for match in re.finditer(_CURRENT_SUICIDE_OR_SELF_HARM, text)
if not _match_has_invalid_crisis_context(text, match)
]
help_matches = [
match
for match in _REAL_HELP_CUE.finditer(text)
if not _match_has_invalid_crisis_context(text, match)
]
return any(
abs(direct.start() - help_match.start()) <= 160
for direct in direct_matches
for help_match in help_matches
)
def crisis_signal_matches(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):
if not _match_has_invalid_crisis_context(text, term_match):
matched.append(term)
break
if any(
not _match_has_invalid_crisis_context(text, match)
for match in _INDIRECT_SELF_ERASURE_CUE.finditer(text)
):
matched.append(_INDIRECT_SELF_ERASURE)
if _current_personal_danger_help(text):
matched.append(_CURRENT_PERSONAL_DANGER_HELP)
if _first_person_current_crisis(text):
matched.append(_FIRST_PERSON_CURRENT_CRISIS)
return matched
def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> CrisisResult:
@ -486,19 +732,20 @@ def classify_crisis(text: str, *, speaker_is_persona_context: bool = True) -> Cr
"""
if not text:
return CrisisResult(kind=CrisisKind.NONE)
matched = [t for t in _CRISIS_TERMS if t in text]
matched = crisis_signal_matches(text)
if not matched:
return CrisisResult(kind=CrisisKind.NONE)
risk = min(5, 2 + len(matched)) # 거친 단계화(휴리스틱)
real_cues = [c for c in _FIRST_PERSON_NOW if c in text]
explicit_real_signal = _CURRENT_PERSONAL_DANGER_HELP in matched
explicit_current_crisis = _FIRST_PERSON_CURRENT_CRISIS in matched
if not speaker_is_persona_context or real_cues:
if not speaker_is_persona_context or explicit_real_signal or explicit_current_crisis:
# 실제 위기 가능 → 에스컬레이션(보수적)
return CrisisResult(
kind=CrisisKind.LEARNER_REAL,
risk_level=max(risk, 4),
matched=matched + real_cues,
matched=matched,
escalate=True,
)
# 시뮬레이션 연기 맥락