위기 신호 기술 검증을 보강

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

@ -15,6 +15,7 @@ from . import session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role
from .engine_client import EngineError, GenerateResponse
from .paths import repo_path
from .routes import eval as eval_routes
from .routes import sessions
from .routes import voice as voice_routes
@ -80,6 +81,21 @@ def _session(principal: Principal) -> InProcSession:
return sess
def _crisis_case_signal(case_id: str) -> str:
case_set = json.loads(
repo_path(
"data",
"clinical",
"p1-crisis-review-cases.json",
).read_text(encoding="utf-8")
)
return next(
case["synthetic_scenario"]["signal"]
for case in case_set["cases"]
if case["case_id"] == case_id
)
async def _consume_event_source(response: object) -> bytes:
body = bytearray()
iterator = getattr(response, "body_iterator")
@ -726,7 +742,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
with patch.object(sessions.engine_client, "generate", should_not_generate):
response = await sessions.submit_turn(
sess.session_id,
sessions.TurnRequest(text="저 지금 죽고싶어요. 도와주세요."),
sessions.TurnRequest(text=_crisis_case_signal("P1-CRISIS-004")),
principal,
)
@ -918,7 +934,7 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."),
sessions.TurnRequest(text=_crisis_case_signal("P1-CRISIS-004")),
principal,
)
body = await _consume_event_source(response)
@ -1446,6 +1462,113 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(official.citation)
engine.generate.assert_called_once()
async def test_exact_persona_crisis_cases_fail_closed_to_risk_coaching(self) -> None:
expected_locators = {
"P1-CRISIS-001": "사고·계획·의도·행동 구분",
"P1-CRISIS-002": "사고·계획·의도·행동 구분",
"P1-CRISIS-005": "대한민국 109·119 긴급 연결",
"P1-CRISIS-006": "자해 이후 심리사회적 평가",
}
for case_id, expected_locator in expected_locators.items():
with self.subTest(case_id=case_id):
item = live_coach.LiveCoachInput(
session_id=f"{case_id.lower()}-coach",
turn_seq=4,
stage="exploration",
effective_openness=0.45,
theory_mode="humanistic",
persona_code="P1",
persona_name="서연",
learner_text="그 마음을 조금 더 들려줄 수 있을까요?",
client_reply=_crisis_case_signal(case_id),
recent_turns=[],
)
engine = SimpleNamespace(
generate=AsyncMock(side_effect=AssertionError("crisis coaching must bypass engine"))
)
suggestion = await live_coach.generate_live_coaching(item, engine=engine)
self.assertEqual(suggestion.status, "ready")
self.assertEqual(suggestion.focus, "risk")
self.assertEqual(suggestion.tone, "warn")
self.assertIsNotNone(suggestion.safety_note)
self.assertIn("가상내담자 위기 신호", suggestion.safety_note or "")
self.assertNotEqual(suggestion.safety_note, guardrail.CRISIS_RESOURCE_MESSAGE)
self.assertIn(
expected_locator,
[source.locator for source in suggestion.sources],
)
engine.generate.assert_not_called()
if case_id == "P1-CRISIS-005":
self.assertIn("109", suggestion.message)
self.assertIn("119", suggestion.message)
self.assertIn("응급실", suggestion.message)
self.assertIn("혼자 두지", suggestion.message)
if case_id == "P1-CRISIS-002":
for required in (
"최근성",
"빈도",
"강도",
"계획 유무",
"의도",
"접근 가능성",
"지지자",
"살아갈 이유",
"도움 요청 가능성",
):
self.assertIn(required, suggestion.message)
if case_id == "P1-CRISIS-006":
self.assertIn("비판단", suggestion.message)
self.assertIn("심리사회적", suggestion.message)
self.assertIn("구체적인 수단", suggestion.message)
self.assertIn("전문기관", suggestion.message)
self.assertIn("추후 확인", suggestion.message)
async def test_live_coach_does_not_short_circuit_nonclinical_keyword_contexts(self) -> None:
client_replies = (
"이번 발표에 목숨 걸 만큼 열심히 했어요.",
"자살 예방 교육에서 109를 배웠어요.",
"게임에서 캐릭터 목숨이 하나 남았어요.",
"저는 지금 자살하고 싶지 않아요.",
"요즘은 죽고 싶지 않아요.",
"저는 지금 죽고 싶은 건 아니에요.",
"“저는 지금 죽고 싶어요” 문장을 번역해 주세요.",
"저는 지금 죽고 싶은 생각이 없어요.",
"저는 지금 죽고 싶은 마음이 전혀 없습니다.",
"제가 지금 죽고 싶다고 말한 학생을 돕고 있어요.",
"제가 지금 죽고 싶다고 한 친구를 돕고 있어요.",
"저는 지금 자살 생각이 없어요.",
"저는 지금 자해 충동이 없어요.",
"저는 예전에 죽고 싶었지만 지금은 괜찮아요.",
"제가 지금 여쭤보는 건 자살 생각이 있는지예요.",
"제가 지금 묻는 질문은 혹시 죽고 싶은 생각이 있는지예요.",
"누구나 지금 자살하고 싶다고 말하는 것은 아니에요.",
)
for index, client_reply in enumerate(client_replies, start=1):
with self.subTest(client_reply=client_reply):
item = live_coach.LiveCoachInput(
session_id=f"nonclinical-keyword-{index}",
turn_seq=2,
stage="exploration",
effective_openness=0.5,
theory_mode="humanistic",
persona_code="P1",
persona_name="서연",
learner_text="그 경험을 조금 더 들려줄 수 있을까요?",
client_reply=client_reply,
recent_turns=[],
)
engine = SimpleNamespace(generate=AsyncMock(side_effect=EngineError("offline")))
suggestion = await live_coach.generate_live_coaching(item, engine=engine)
engine.generate.assert_awaited_once()
self.assertNotEqual(suggestion.focus, "risk")
async def test_live_coach_prompt_uses_client_role_token_not_persona_name(
self,
) -> None: