위기 신호 기술 검증을 보강
This commit is contained in:
parent
6988280b30
commit
35a9d8802c
5 changed files with 1164 additions and 14 deletions
|
|
@ -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,
|
||||
)
|
||||
# 시뮬레이션 연기 맥락
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue