안전 이벤트 대시보드 보강
This commit is contained in:
parent
fe2796f05a
commit
51053af536
15 changed files with 1081 additions and 423 deletions
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue