위기 신호 기술 검증을 보강

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

@ -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()