위기 문장 문맥 판정을 보강

This commit is contained in:
Yun Chan 2026-08-28 20:20:15 +09:00
parent aeca42475b
commit f63e12999b
3 changed files with 286 additions and 46 deletions

View file

@ -469,19 +469,25 @@ _CRISIS_TERMS = [
"죽고 싶", "죽고싶", "자살 생각", "자살하고 싶", "자살을 생각", "자살 충동",
"자살 시도", "목숨을 끊", "목숨을 버리", "사라지고 싶", "없어지고 싶",
"자해하고 싶", "자해한 적", "자해를 했", "자해 충동", "자해 시도",
"자살 계획", "자해 계획", "자살하려고", "자해하려고", "죽으려고",
"자살 계획", "자해 계획", "자살하려고", "자살을 하려고", "자해하려고",
"자해를 하려고", "죽으려고", "자살하려", "죽으려", "자살할 계획",
"자해할 계획", "죽을 계획", "자살할 의도", "죽을 준비",
"제 자신을 해치려고", "삶을 끝내려고", "목숨을 끊을 계획",
"스스로를 다치게", "스스로를 해칠 계획", "자살할지도", "끝내고 싶",
"살기 싫", "살아서 뭐", "죽어야", "안전을 스스로 지키기 어렵",
"안전을 유지할 수 없",
"안전을 유지할 수 없", "안전을 보장할 수 없",
]
# 자살·자해 단어를 직접 쓰지 않더라도 본인의 현재 위험과 실제 도움 필요를 함께
# 밝히는 짧은 진술은 엔진 호출 전에 막아야 한다. 단일 '위험'/'도움' 키워드만으로는
# 상담자의 안전 질문까지 오탐할 수 있어 네 단서가 모두 함께 있을 때만 승격한다.
_SELF_SUBJECT = (
r"(?<![가-힣A-Za-z0-9])"
r"(?:저\s*(?:자신|스스로)|나\s*(?:자신|스스로)|저한테|나한테|제가|저는|저|내가|나는|나)"
r"(?:저\s*(?:자신|스스로)|나\s*(?:자신|스스로)|제\s*자신|내\s*자신|"
r"저한테|나한테|제가|저는|저|내가|나는|나)"
)
_CURRENT = r"(?:지금|현재|오늘)(?:은|도)?"
_CURRENT = r"(?:지금|현재|오늘|내일)(?:은|도)?"
_CURRENT_QUALIFIER = r"(?:(?:정말(?:로)?|진짜(?:로)?|실제로|곧|당장)\s*)*"
_CURRENT_DAY_PART = r"(?:(?:밤|저녁|새벽)\s*)?"
_DANGER = (
r"위험(?:한\s*(?:것\s*같|상태)|할\s*것\s*같|하다고\s*(?:느끼|느껴)|"
r"하(?:고|며)|해(?:요|서)?)"
@ -505,7 +511,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*"
@ -528,36 +534,56 @@ _PERSONAL_IMMEDIATE_HARM_PATTERNS = (
rf"다치게\s*할까\s*봐|다치게\s*할\s*것\s*같)"
),
)
_HIGH_RISK_ASSERTION = (
r"(?:"
r"(?:자살|자해)(?:을|를)?\s*시도(?:를)?\s*"
r"(?:(?:하)?(?:려고|려)\s*(?:해요|합니다)|할지도\s*(?:모르|몰라))|"
r"(?:자살|자해)(?:을|를)?\s*(?:하)?(?:려고|려)\s*(?:해요|합니다)|"
r"(?:자살|자해)할\s*(?:것\s*같|지도\s*(?:모르|몰라)|거(?:예요|에요|야|다)|래요)|"
r"(?:자살|자해)(?:을|를)?\s*"
r"(?:결심(?:했|한)|준비(?:를\s*)?(?:하고|했|하는)|실행(?:을\s*)?(?:하려고|할))|"
r"(?:자살|자해)하기로\s*(?:했|결심)|"
r"(?:자살|자해)\s*생각(?:이|은)?\s*(?:계속\s*)?반복|"
r"(?:자살|자해)\s*(?:계획|충동|생각)(?:이|가|은|는)?\s*(?:있|세웠)|"
r"(?:자살할|자해할|죽을|(?:제|내)\s*목숨을\s*끊을)\s*"
r"(?:구체적인\s*)?(?:계획|의도)(?:이|가)?\s*(?:있|이에요|입니다)|"
r"(?:자살|자해)\s*계획(?:을|은|는)?\s*세웠|"
r"죽(?:으려고|으려)\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*(?:해요|합니다)|"
r"해칠\s*계획(?:이|가|은|는)?\s*(?:있|세웠))|"
r"삶을\s*끝내려고\s*(?:해요|합니다)|"
r"(?:(?:제|내)\s*)?안전을?\s*(?:지킬|유지할|보장할|지키기|유지하기|보장하기)\s*"
r"(?:수\s*없|어렵|어려)"
r")"
)
_CURRENT_SUICIDE_OR_SELF_HARM = (
r"(?:자살(?:하고)?\s*싶|자살(?:하)?려고\s*(?:해요|합니다)|"
r"자살할\s*(?:것\s*같|지도\s*모르)|죽고\s*싶|"
r"죽으려고\s*(?:해요|합니다)|"
r"죽어버리고\s*싶|살기\s*싫|살고\s*싶지\s*않|"
r"자해(?:하고)?\s*싶|자해(?:하)?려고\s*(?:해요|합니다)|"
r"자해할\s*(?:것\s*같|지도\s*모르)|"
r"(?:스스로를|저를|나를)\s*(?:다치게\s*하고|해치고)\s*싶|"
r"(?:제|내)\s*목숨을\s*(?:끊고\s*싶|끊으려고\s*(?:해요|합니다))|"
r"끝내고\s*싶|없어지고\s*싶|죽어야\s*할\s*것\s*같|"
r"살아갈\s*이유가\s*없|자해\s*충동이\s*있)"
rf"(?:{_HIGH_RISK_ASSERTION}|"
r"자살(?:하고)?\s*싶|죽고\s*싶|죽어버리고\s*싶|"
r"살기\s*싫|살고\s*싶지\s*않|자해(?:하고)?\s*싶|"
r"(?:삶을\s*)?끝내고\s*싶|(?:사라지고|없어지고)\s*싶|"
r"죽어야\s*할\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}"
rf"{_CURRENT_DAY_PART}{_CURRENT_QUALIFIER}{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_CURRENT}\s*{_SELF_SUBJECT}(?:이|은|도)?\s*"
rf"(?:정말\s*|진짜\s*)?{_CURRENT_SUICIDE_OR_SELF_HARM}"
rf"{_CURRENT_DAY_PART}{_CURRENT_QUALIFIER}{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*(?:정말|진짜)\s*"
rf"{_SELF_SUBJECT}(?:이|은|도)?\s*{_CURRENT_QUALIFIER}"
rf"{_CURRENT_SUICIDE_OR_SELF_HARM}"
),
re.compile(
rf"{_CURRENT}\s*(?:당장\s*)?"
r"(?:자살(?:하)?려고\s*(?:해요|합니다)|"
r"죽으려고\s*(?:해요|합니다)|"
r"자해(?:하)?려고\s*(?:해요|합니다))"
rf"{_CURRENT}\s*{_CURRENT_DAY_PART}{_CURRENT_QUALIFIER}"
rf"{_HIGH_RISK_ASSERTION}"
),
)
_FIRST_PERSON_EXPLICIT_CRISIS_PATTERNS = (
@ -581,23 +607,21 @@ _CURRENT_PERSONAL_DANGER_HELP = "first_person_current_danger_help"
_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")"
rf"(?<![가-힣A-Za-z0-9]){_HIGH_RISK_ASSERTION}"
)
_INDIRECT_SELF_ERASURE = "indirect_self_erasure"
_INDIRECT_SELF_ERASURE_CUE = re.compile(
r"(?<![가-힣A-Za-z0-9])(?:차라리\s*(?:없어졌으면|사라졌으면)|"
r"(?:내가|제가|저\s*자신이|나\s*자신이)\s*(?:없어졌으면|사라졌으면))"
)
_OTHER_PERSON_SUBJECT = (
r"(?:내담자|상대|학생|친구|배우자|가족|동생|아버지|아빠|어머니|엄마|"
r"부모|동료|그\s*사람)"
)
_REPORTED_OR_QUOTED_QUESTION = re.compile(
r"(?:내담자|상대|학생)(?:가|은|는|에게)?.{0,180}(?:라고|다고|냐고|는지)"
r".{0,40}(?:말하|묻|물어|확인|진술|표현)|"
rf"{_OTHER_PERSON_SUBJECT}(?:이|가|은|는|에게)?.{{0,180}}"
r"(?:라고|다고|냐고|는지).{0,40}"
r"(?:말(?:하|해|했)|묻|물어|확인|진술|표현)|"
r"[\"'“‘].{1,180}[\"'”’]\s*(?:라고|다고|냐고|는지)?.{0,60}"
r"(?:예시|읽|말하|가정|연습|문장|번역|질문|확인|묻|물어)"
)
@ -609,7 +633,11 @@ _REPORTED_CRISIS_HELPING_OTHER = re.compile(
_CRISIS_META_QUESTION = re.compile(
r"(?:죽고\s*싶(?:은\s*생각이\s*있는|으신|은)?지|"
r"자살(?:하고)?\s*싶(?:으신|은)?지|자해(?:하고)?\s*싶(?:으신|은)?지)"
r".{0,50}(?:묻|물어|여쭙|여쭤|질문|확인|연습)"
r".{0,50}(?:묻|물어|여쭙|여쭤|질문|확인|연습)|"
r"(?:자살|자해)\s*(?:생각|계획|충동)(?:이|가)?\s*있는지"
r".{0,50}(?:묻|물어|여쭙|여쭤|질문|확인|연습)|"
r"(?:자살|자해)\s*(?:생각|계획|충동)(?:이|가)?\s*"
r"있(?:나요|습니까|으신가요)"
)
_CRISIS_META_QUESTION_PREFIX = re.compile(
r"(?:묻|물어|여쭙|여쭤|질문|확인).{0,55}"
@ -618,13 +646,20 @@ _CRISIS_META_QUESTION_PREFIX = re.compile(
)
_OTHER_PERSON_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?)\s*싶(?:은|어\s*하는|다는)\s*"
r"(?:친구|내담자|학생|사람|가족|동생)"
rf"{_OTHER_PERSON_SUBJECT}"
)
_CRISIS_META_OR_ROLEPLAY = re.compile(
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r"(?:(?:다고|냐고|은지|는지|다는).{0,60}"
r"(?:가정|역할극|연습|예시|읽|번역|문장|말하|묻|물어|질문|확인)|"
r"으면.{0,60}(?:설명|안내|교육|전화))"
r"으면.{0,60}(?:설명|안내|교육|전화))|"
r"(?:자살|자해|죽|목숨|안전).{0,75}"
r"(?:있는지|없는지|할지|인지).{0,45}"
r"(?:확인|묻|물어|여쭙|여쭤|질문|연습)|"
r"(?:자살|자해|죽|목숨|안전).{0,75}"
r"(?:문장|번역|설문|설명|역할극|연습|예시)|"
r"(?:역할극|연습|예시).{0,55}(?:자살|자해|죽|목숨|안전).{0,55}"
r"(?:라고|다고|라는|다는|대사|문장|말하|연기)"
)
_NEGATED_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?|끝내고)\s*"
@ -633,12 +668,26 @@ _NEGATED_CRISIS = re.compile(
r"(?:자살(?:하고)?|죽고|자해(?:하고)?)\s*싶은\s*(?:생각|마음)"
r"(?:이|은)?\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*않|"
r"살기\s*싫(?:지|지는)\s*않|(?:없어지고|사라지고)\s*싶(?:지|지는|진)\s*않|"
r"죽어야\s*할\s*것\s*같(?:지|지는|진)\s*않|"
r"살아갈\s*이유가\s*없(?:지|지는)\s*않|"
r"살아갈\s*이유가\s*없는\s*(?:건|것은)\s*아니|"
r"자해\s*충동이\s*있지는\s*않|"
r"(?:자살|자해)\s*충동이\s*있(?:지|지는)\s*않|"
r"(?:자살|자해)\s*(?:계획|생각)(?:이|가)?\s*있는\s*"
r"(?:게|건|것은)\s*아니|"
r"(?:자살|자해)\s*(?:계획|충동|생각|시도)(?:이|가|은|는)?\s*"
r"(?:전혀\s*)?(?:없|있지는\s*않|하지\s*않)|"
r"살고\s*싶지\s*않은\s*(?:게|건|것은)\s*아니|"
r"(?:(?:제|내)\s*)?안전을?\s*(?:지킬|유지할|보장할)\s*수\s*없는\s*"
r"(?:게|건|것은)\s*아니|"
r"(?:자살|자해)(?:을|를)?\s*시도(?:를)?\s*(?:하)?려는\s*"
r"(?:게|건|것은)\s*아니|"
r"(?:자살|자해)(?:을|를)?\s*(?:하)?려고\s*하(?:지|지는|진)\s*않|"
r"죽으려고\s*하(?:지|지는|진)\s*않|"
r"(?:자살할|자해할|죽을|(?:제|내)\s*목숨을\s*끊을)\s*"
r"(?:구체적인\s*)?(?:계획|의도)(?:이|가|은|는)?\s*없|"
r"죽을\s*준비(?:를|가|는)?\s*(?:하)?지\s*않|"
r"위험(?:한|할)\s*것\s*같지\s*않|도움(?:이|을)?\s*필요하지\s*않"
)
_RECOVERED_PAST_CRISIS = re.compile(
@ -646,6 +695,9 @@ _RECOVERED_PAST_CRISIS = re.compile(
r"(?:자살(?:하고)?\s*싶|죽고\s*싶|자해(?:하고)?\s*싶)"
r"(?:었지만|었으나|었는데).{0,45}(?:지금|현재)(?:은|는)?\s*"
r"(?:괜찮|안전|그렇지\s*않|생각이\s*없)"
r"|(?:예전|과거|아까)(?:에는?|는)?.{0,35}"
r"(?:자살|자해)\s*(?:계획|충동|생각)(?:이|가)?\s*있었지만"
r".{0,35}(?:지금|현재)(?:은|는)?\s*없"
)
@ -660,6 +712,12 @@ _INVALID_REAL_CRISIS_CONTEXTS = (
_RECOVERED_PAST_CRISIS,
)
_CRISIS_CLAUSE_SPLITTER = re.compile(
r"(?<=[.!?。!?])\s+|"
r"(?:(?<=지만)|(?<=으나)|(?<=는데))\s+"
r"(?=(?:저|제|나|내))"
)
def _match_has_invalid_crisis_context(text: str, match: re.Match[str]) -> 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:
"""위기 분류.