안전 이벤트 대시보드 보강

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

@ -156,6 +156,7 @@ async def healthcheck() -> bool:
to_regclass('app.notification_delivery') IS NOT NULL AS has_notification_delivery,
to_regclass('app.sessions') IS NOT NULL AS has_sessions,
to_regclass('app.turns') IS NOT NULL AS has_turns,
to_regclass('app.safety_events') IS NOT NULL AS has_safety_events,
to_regclass('app.session_review_status') IS NOT NULL AS has_session_review_status,
EXISTS (
SELECT 1 FROM information_schema.columns
@ -171,6 +172,21 @@ async def healthcheck() -> bool:
GROUP BY table_schema, table_name
HAVING count(*) = 5
) AS has_turn_voice_metadata_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
AND table_name = 'safety_events'
AND column_name IN (
'session_id',
'trigger_type',
'ko_risk_level',
'escalated',
'detail',
'created_at'
)
GROUP BY table_schema, table_name
HAVING count(*) = 6
) AS has_safety_event_columns,
EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = 'app'
@ -198,8 +214,10 @@ async def healthcheck() -> bool:
and row["has_notification_delivery"]
and row["has_sessions"]
and row["has_turns"]
and row["has_safety_events"]
and row["has_session_review_status"]
and row["has_turn_voice_metadata_columns"]
and row["has_safety_event_columns"]
and row["has_session_review_worksheet_columns"]
)
except Exception:

View file

@ -1324,6 +1324,7 @@ async def stream_turn(
)
async def event_generator():
finalized_turn = False
last_beat = asyncio.get_running_loop().time()
final_reply = ""
try:
@ -1338,15 +1339,43 @@ async def stream_turn(
yield {"event": "token", "data": text}
elif ev.event == "done":
data = {**ev.data, "stage": _stage_label(ctx.state_after.stage)}
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
if not finalized_turn:
evaluation = await _evaluate_stream_turn(ctx, final_reply)
result = _stream_result_from_done(ctx, final_reply, data, evaluation)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
result,
context_prefix="session",
)
finalized_turn = True
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
elif (
ev.event == "safety"
and bool(ev.data.get("conversation_stopped"))
and ctx.crisis is not None
and ctx.crisis.escalate
and not finalized_turn
):
safety_data = {
"session_id": ctx.session_id,
"stage": _stage_label(ctx.state_after.stage),
"effective_openness": round(ctx.state_after.effective_openness, 4),
"turn_seq": ctx.state_after.turn_seq,
"safety_flagged": True,
"crisis_kind": ctx.crisis.kind.value,
"crisis_resource": ev.data.get("crisis_resource"),
"conversation_stopped": True,
}
result = _stream_result_from_done(ctx, final_reply, safety_data, None)
await turn_runtime.finalize_completed_turn(
sess,
ctx,
result,
context_prefix="session",
)
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
finalized_turn = True
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}
else:
yield {"event": ev.event, "data": json.dumps(ev.data, ensure_ascii=False)}

View file

@ -304,6 +304,8 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
)
for item in raw_alerts
]
else:
require_runtime_fallback_allowed("safety alert list")
if sessions:
message = "현재 기록된 실제 학습 세션만 표시합니다."

View file

@ -5,6 +5,7 @@ from __future__ import annotations
import time
import uuid
import hashlib
import json
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable, Protocol
@ -197,6 +198,27 @@ def _model_payload(value: Any) -> dict[str, Any]:
return {}
def _json_object_payload(value: Any) -> dict[str, Any]:
if isinstance(value, dict):
return dict(value)
if not isinstance(value, str):
return {}
text = value.strip()
for _ in range(2):
if not text:
return {}
try:
parsed = json.loads(text)
except json.JSONDecodeError:
return {}
if isinstance(parsed, dict):
return parsed
if not isinstance(parsed, str):
return {}
text = parsed.strip()
return {}
def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
text = (value or "").strip()
if not text:
@ -1023,6 +1045,49 @@ async def ensure_review_tables() -> None:
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.safety_events (
id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
session_id UUID REFERENCES app.sessions(id) ON DELETE CASCADE,
turn_id UUID REFERENCES app.turns(id) ON DELETE SET NULL,
trigger_type TEXT NOT NULL,
ko_risk_level SMALLINT,
escalated BOOLEAN NOT NULL DEFAULT FALSE,
latency_ms INT,
detail JSONB,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_safety_session
ON app.safety_events(session_id);
ALTER TABLE app.safety_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_safety_events_select ON app.safety_events;
DROP POLICY IF EXISTS p_safety_events_insert ON app.safety_events;
CREATE POLICY p_safety_events_select
ON app.safety_events FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.safety_events.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_safety_events_insert
ON app.safety_events FOR INSERT WITH CHECK (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.safety_events.session_id
AND s.learner_id = app.current_uid()
)
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_review_status (
@ -2557,7 +2622,7 @@ async def list_safety_alerts(
"trigger_type": str(row["trigger_type"] or "crisis"),
"ko_risk_level": int(row["ko_risk_level"] or 0),
"escalated": bool(row["escalated"]),
"detail": dict(row["detail"] or {}),
"detail": _json_object_payload(row["detail"]),
"created_at": _iso_dt(row["created_at"]),
}
for row in rows

View file

@ -10,7 +10,7 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from . import auth_sessions, session_persistence
from . import auth_sessions, db, session_persistence
from .config import Settings, settings
from .deps import Principal, Role
from .routes import admin as admin_routes
@ -229,6 +229,57 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertIn(column, conn.query)
self.assertIn("HAVING count(*) = 5", conn.query)
async def test_healthcheck_requires_safety_events_schema(self) -> None:
class MissingSafetyEventsConn:
def __init__(self) -> None:
self.query = ""
async def fetchrow(self, query: str, *args, **kwargs):
self.query = query
return {
"has_user": True,
"has_auth_session": True,
"has_preferences": True,
"has_engine_config": True,
"has_admin_health_event": True,
"has_admin_health_daily_rollup": True,
"has_support_ticket": True,
"has_notification_event": True,
"has_notification_delivery": True,
"has_sessions": True,
"has_turns": True,
"has_safety_events": False,
"has_session_review_status": True,
"has_turn_voice_metadata_columns": True,
"has_safety_event_columns": False,
"has_session_review_worksheet_columns": True,
}
class MissingSafetyEventsAcquire:
def __init__(self, conn: MissingSafetyEventsConn) -> None:
self.conn = conn
async def __aenter__(self) -> MissingSafetyEventsConn:
return self.conn
async def __aexit__(self, exc_type, exc, tb) -> None:
return None
class MissingSafetyEventsPool:
def __init__(self, conn: MissingSafetyEventsConn) -> None:
self.conn = conn
def acquire(self):
return MissingSafetyEventsAcquire(self.conn)
conn = MissingSafetyEventsConn()
with patch.object(db, "get_pool", return_value=MissingSafetyEventsPool(conn)):
healthy = await db.healthcheck()
self.assertFalse(healthy)
self.assertIn("to_regclass('app.safety_events')", conn.query)
self.assertIn("has_safety_event_columns", conn.query)
async def test_staging_uses_env_engine_config_when_row_missing(self) -> None:
class EmptyConfigConn:
async def fetchrow(self, *args, **kwargs):

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

View file

@ -7,6 +7,7 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from .config import settings
from .deps import Principal, Role
from .routes import teacher
from .services import state_machine
@ -186,6 +187,45 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(closed_summary.review_status, "closed")
async def test_dashboard_fails_closed_when_safety_alerts_are_not_durable_outside_dev(self) -> None:
principal = _principal()
sess = _session(
session_id="00000000-0000-0000-0000-00000000b211",
session_no=1,
learner_id="00000000-0000-0000-0000-000000000333",
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
)
previous_environment = settings.environment
settings.environment = "staging"
try:
with (
patch.object(
teacher.session_persistence,
"list_sessions",
AsyncMock(return_value=([sess], True)),
),
patch.object(
teacher.session_persistence,
"list_safety_alerts",
AsyncMock(return_value=([], False)),
),
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(return_value=({}, True)),
),
):
with self.assertRaises(HTTPException) as raised:
await teacher.teacher_dashboard(principal)
finally:
settings.environment = previous_environment
self.assertEqual(raised.exception.status_code, 503)
self.assertIn("safety alert list persistence unavailable", raised.exception.detail)
async def test_teacher_can_mark_session_review_closed_with_note(self) -> None:
principal = _principal()
sess = _session(

View file

@ -7,7 +7,7 @@
from __future__ import annotations
from enum import Enum
import json
import logging
from typing import Optional
from . import db, session_persistence
@ -17,6 +17,8 @@ from .services import guardrail, orchestrator, state_machine
from .stage_contract import stage_label
from .store import InProcSession, TurnRecord, store
logger = logging.getLogger(__name__)
class SessionAccessError(str, Enum):
NOT_FOUND = "not_found"
@ -147,8 +149,16 @@ async def record_safety_event(
if crisis is None or not getattr(crisis, "escalate", False):
return
kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis"))
detail = {
"matched": list(getattr(crisis, "matched", []) or []),
"stage": getattr(result, "stage", None),
"turn_seq": getattr(result, "turn_seq", None),
"conversation_stopped": getattr(result, "conversation_stopped", False),
"crisis_resource": getattr(result, "crisis_resource", None),
"alert_status": "teacher_dashboard",
}
try:
async with db.acquire() as conn:
async with db.acquire(ai_context=True) as conn:
await conn.execute(
"""
INSERT INTO app.safety_events
@ -158,17 +168,15 @@ async def record_safety_event(
sess.session_id,
kind,
int(getattr(crisis, "risk_level", 0) or 0),
json.dumps({
"matched": list(getattr(crisis, "matched", []) or []),
"stage": getattr(result, "stage", None),
"turn_seq": getattr(result, "turn_seq", None),
"conversation_stopped": getattr(result, "conversation_stopped", False),
"crisis_resource": getattr(result, "crisis_resource", None),
"alert_status": "teacher_dashboard",
}),
detail,
)
except Exception:
pass
logger.exception(
"safety event persistence failed: session_id=%s trigger_type=%s",
sess.session_id,
kind,
)
require_runtime_fallback_allowed("safety event")
async def finalize_completed_turn(