위기 신호 기술 검증을 보강

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,
)
# 시뮬레이션 연기 맥락

View file

@ -46,6 +46,7 @@ CoachFocus = Literal[
"goal",
"pacing",
]
SafetySignalKind = Literal["ideation", "self_harm", "urgent"]
class LiveCoachSource(BaseModel):
@ -180,6 +181,35 @@ def _configured_model(value: str | None) -> str | None:
return model or None
_SELF_HARM_SIGNAL_CUES = ("자해", "스스로를 다치게", "스스로 다치게")
_URGENT_SAFETY_SIGNAL_CUES = (
"안전을 스스로 지키기 어렵",
"안전을 지키기 어렵",
"위험할 것 같",
)
_PERSONA_SAFETY_NOTE = (
"가상내담자 위기 신호에 대한 훈련 코칭입니다. 즉각 위험을 직접 확인하고 기관 지침에 따라 "
"보호체계 연결과 지도·지원을 이어가세요."
)
def _safety_signal_kind(item: LiveCoachInput) -> SafetySignalKind | None:
"""이번 턴의 위기 내용을 화자 역할과 분리해 코칭 우선순위로 바꾼다."""
client_text = item.client_reply or ""
client_matches = guardrail.crisis_signal_matches(client_text)
learner_crisis = guardrail.classify_crisis(item.learner_text)
if not client_matches and learner_crisis.kind == guardrail.CrisisKind.NONE:
return None
signal_text = f"{item.learner_text} {client_text}"
if any(cue in signal_text for cue in _URGENT_SAFETY_SIGNAL_CUES):
return "urgent"
if any(cue in signal_text for cue in _SELF_HARM_SIGNAL_CUES):
return "self_harm"
return "ideation"
@lru_cache(maxsize=1)
def _local_source_entries() -> tuple[tuple[str, dict[str, Any]], ...]:
"""Load repo-managed live-coach source packs.
@ -386,6 +416,7 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
" ".join(str((item.evaluation or {}).get(k, "")) for k in ("appropriateness", "appropriateness_note")),
]
).lower()
safety_kind = _safety_signal_kind(item)
scored: list[tuple[int, int, dict[str, Any], dict[str, Any]]] = []
fallback: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
for source_index, payload in enumerate(_local_source_payloads()):
@ -399,6 +430,16 @@ def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[
fallback.append((source_index * 1000 + chunk_index, source, chunk))
keywords = [str(k).lower() for k in (chunk.get("keywords") or [])]
score = sum(1 for kw in keywords if kw and kw in query)
if safety_kind and source.get("source_id") == "official_suicide_risk_guidelines":
chunk_id = str(chunk.get("id") or "")
if chunk_id == "risk-ideation-plan-intent-behavior":
score += 80
if safety_kind == "self_harm" and chunk_id == "self-harm-psychosocial-assessment":
score += 120
if safety_kind == "urgent" and chunk_id == "safety-plan-not-contract":
score += 130
if safety_kind == "urgent" and chunk_id == "korea-109-emergency-connection":
score += 140
if score > 0:
priority = int(source.get("priority") or 50)
scored.append((score, priority, source, chunk))
@ -586,11 +627,19 @@ def _fallback_suggestion(
reason: Optional[str] = None,
) -> LiveCoachSuggestion:
text = item.learner_text
safety_kind = _safety_signal_kind(item)
learner_crisis = guardrail.classify_crisis(item.learner_text)
safety_note_for_signal = (
guardrail.CRISIS_RESOURCE_MESSAGE
if learner_crisis.escalate
else _PERSONA_SAFETY_NOTE
)
low_open = item.effective_openness < 0.35
tone: Tone = _evaluation_tone(item.evaluation)
focus: CoachFocus = "exploration"
title = "다음 탐색"
message = "내담자 표현을 한 번 반영한 뒤, 방금 말한 장면을 더 구체적으로 물어봐라."
safety_note: Optional[str] = None
# 단계별 기본 다음 발화 — 폴백에서도 회기 흐름에 맞는 제안을 낸다.
stage_next_lines = {
"라포": "오늘 이렇게 시간 내줘서 고마워요. 지금 마음이 어떤지 편한 만큼만 들려줄래요?",
@ -603,14 +652,39 @@ def _fallback_suggestion(
"방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?",
)
crisis = guardrail.classify_crisis(text)
q = (item.question or "").strip()
if crisis.kind != guardrail.CrisisKind.NONE:
if safety_kind == "urgent":
tone = "warn"
focus = "risk"
title = "긴급 안전 연결"
message = (
"일반 회기 목표를 멈추고 현재 안전을 확인해라. 혼자 두지 말고 현장 지지자와 109를 연결하며, "
"즉각 위험하면 119 또는 가까운 응급실을 우선한다."
)
next_line = "지금 혼자 계신가요? 곁에 함께 있어 줄 사람과 즉시 연결해도 될까요?"
safety_note = safety_note_for_signal
elif safety_kind == "self_harm":
tone = "warn"
focus = "risk"
title = "자해 안전 확인"
message = (
"자해를 관심 끌기나 의지 문제로 단정하지 말고 현재 안전, 심리사회적 맥락, 반복 위험과 "
"지지체계를 비판단적으로 확인해라. 구체적인 수단을 캐묻지 말고, 필요한 전문기관에 "
"연결한 뒤 안전 상태를 추후 확인할 계획을 함께 세워라."
)
next_line = "지금은 안전한지, 그리고 다시 다치지 않도록 곁에서 도와줄 사람이 있는지 확인해도 될까요?"
safety_note = safety_note_for_signal
elif safety_kind == "ideation":
tone = "warn"
focus = "risk"
title = "안전 먼저"
message = "위험 단서가 나온 턴이다. 방법을 캐묻지 말고 최근성, 강도, 보호요인을 차분히 확인해라."
message = (
"위험 단서를 무시하거나 단정하지 말고 현재 사고의 최근성·빈도·강도, 계획 유무·의도·"
"위험 수단 접근 가능성을 안전 확보에 필요한 범위에서 직접 확인해라. 지지자·살아갈 이유·"
"도움 요청 가능성 같은 보호요인도 확인하되, 구체적 방법을 캐묻거나 묘사·교육하지 않는다."
)
next_line = "그 생각이 최근에 얼마나 자주, 얼마나 강하게 올라오는지 안전을 위해 같이 확인해도 될까요?"
safety_note = safety_note_for_signal
elif q and any(k in q for k in ("저항", "방어", "", "안 열", "거부")):
focus = "rapport"
title = "저항 수용과 안전감"
@ -663,6 +737,7 @@ def _fallback_suggestion(
next_utterance=next_line,
rationale=rationale,
sources=_source_refs(grounding),
safety_note=safety_note,
latency_ms=0,
)
@ -786,7 +861,8 @@ async def generate_live_coaching(
local_grounding = _local_reference_grounding(item)
all_grounding = _external_safe_grounding([*local_grounding, *(grounding or [])])
crisis = guardrail.classify_crisis(item.learner_text)
if crisis.escalate:
client_crisis = bool(guardrail.crisis_signal_matches(item.client_reply or ""))
if crisis.escalate or client_crisis:
return _fallback_suggestion(item, grounding=all_grounding, status="ready")
started = time.perf_counter()