안전 이벤트 대시보드 보강
This commit is contained in:
parent
fe2796f05a
commit
51053af536
15 changed files with 1081 additions and 423 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = "현재 기록된 실제 학습 세션만 표시합니다."
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue