안전 이벤트 대시보드 보강

This commit is contained in:
Yun Chan 2026-07-01 12:34:46 +09:00
parent fe2796f05a
commit 51053af536
15 changed files with 1081 additions and 423 deletions

View file

@ -7,6 +7,9 @@ import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .config import settings
from . import session_persistence, turn_runtime
from .contracts.engine_gateway import EngineGatewaySseLineDecoder
from .deps import Principal, Role
@ -267,20 +270,75 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return None
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()):
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()) as acquire:
await turn_runtime.record_safety_event(sess, ctx, result)
acquire.assert_called_once_with(ai_context=True)
self.assertEqual(len(calls), 1)
query, args = calls[0]
self.assertIn("INSERT INTO app.safety_events", query)
self.assertEqual(args[0], sess.session_id)
self.assertEqual(args[1], "learner_real")
self.assertGreaterEqual(args[2], 4)
detail = json.loads(args[3])
detail = args[3]
self.assertTrue(detail["conversation_stopped"])
self.assertEqual(detail["crisis_resource"]["number"], "109")
self.assertEqual(detail["alert_status"], "teacher_dashboard")
def test_safety_alert_detail_parser_handles_legacy_json_string(self) -> None:
detail = session_persistence._json_object_payload(
'"{\\"crisis_resource\\":{\\"number\\":\\"109\\"},\\"alert_status\\":\\"teacher_dashboard\\"}"'
)
self.assertEqual(detail["crisis_resource"]["number"], "109")
self.assertEqual(detail["alert_status"], "teacher_dashboard")
async def test_record_safety_event_fails_closed_when_insert_fails_outside_dev(self) -> None:
principal = _principal()
sess = _session(principal)
ctx = orchestrator.prepare_turn(
session_id=sess.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text="저 지금 자살하고 싶어요. 도와주세요.",
theory_mode=sess.theory_mode,
)
result = orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq if ctx.state_after else 1,
stage=ctx.state_after.stage.value if ctx.state_after else "라포",
effective_openness=ctx.state_after.effective_openness if ctx.state_after else 0.0,
client_reply=None,
safety_flagged=True,
state_after=ctx.state_after or sess.state,
crisis_kind="learner_real",
crisis_resource={"title": "자살예방상담전화 109", "number": "109"},
conversation_stopped=True,
)
class FakeConn:
async def execute(self, query: str, *args: object) -> str:
raise RuntimeError("safety_events unavailable")
class FakeAcquire:
async def __aenter__(self) -> FakeConn:
return FakeConn()
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return None
previous_environment = settings.environment
settings.environment = "staging"
try:
with patch.object(turn_runtime.db, "acquire", return_value=FakeAcquire()):
with self.assertRaises(HTTPException) as raised:
await turn_runtime.record_safety_event(sess, ctx, result)
finally:
settings.environment = previous_environment
self.assertEqual(raised.exception.status_code, 503)
self.assertIn("safety event persistence unavailable", raised.exception.detail)
async def test_stream_turn_persists_client_engine_telemetry(self) -> None:
principal = _principal()
sess = _session(principal)
@ -328,7 +386,11 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
def should_not_stream(*args, **kwargs):
raise AssertionError("stream engine must not be called for learner_real crisis")
with patch.object(sessions.engine_client, "stream", should_not_stream):
with (
patch.object(sessions.engine_client, "stream", should_not_stream),
patch.object(turn_runtime, "record_completed_turn", new_callable=AsyncMock) as completed_turn,
patch.object(turn_runtime, "record_safety_event", new_callable=AsyncMock) as safety_event,
):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="저 지금 자살하고 싶어요. 도와주세요."),
@ -341,8 +403,12 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("'event': 'done'", rendered)
self.assertIn("109", rendered)
self.assertIn("conversation_stopped", rendered)
self.assertEqual(len(sess.turns), 1)
self.assertEqual(sess.turns[0].speaker, "counselor")
completed_turn.assert_awaited_once()
safety_event.assert_awaited_once()
saved_result = completed_turn.await_args.args[2]
self.assertTrue(saved_result.safety_flagged)
self.assertTrue(saved_result.conversation_stopped)
self.assertEqual(saved_result.crisis_resource["number"], "109")
async def test_stream_turn_persists_fast_loop_evaluation_on_learner_turn(self) -> None:
principal = _principal()