관리자 감정 관측 기록과 조회 API 추가

This commit is contained in:
Yun Chan 2026-09-23 04:45:50 +09:00
parent d22cd9883d
commit acb0d26338
16 changed files with 1445 additions and 18 deletions

View file

@ -0,0 +1,327 @@
"""관리자 감정 관측 조회의 권한·페이지·영속 경계 검증."""
from __future__ import annotations
import unittest
from datetime import datetime, timezone
from unittest.mock import AsyncMock, patch
from uuid import UUID
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
from .contracts.admin_affect import (
AdminAffectRuntimeResponse,
AdminAffectSessionListResponse,
)
from .deps import Principal, Role, get_current_principal
from .routes import admin as admin_routes
from .services import admin_affect
SESSION_ID = UUID("00000000-0000-0000-0000-000000000101")
TURN_ID = UUID("00000000-0000-0000-0000-000000000201")
ADMIN_ID = "00000000-0000-0000-0000-000000000901"
OBSERVED_AT = datetime(2026, 9, 23, 1, 2, 3, tzinfo=timezone.utc)
class _Acquire:
def __init__(self, conn: object) -> None:
self.conn = conn
async def __aenter__(self) -> object:
return self.conn
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return None
def _trace(*, seq: int) -> dict[str, object]:
dimensions = []
for key in (
"anxiety",
"sadness",
"anger",
"shame",
"guilt",
"loneliness",
"relief",
"hope",
"trust",
):
dimensions.append(
{
"key": key,
"before": 0.4,
"target": 0.6,
"after": 0.47,
"confidence": 0.8,
"probabilities": [0.05, 0.1, 0.2, 0.35, 0.3],
"decision": "accepted",
}
)
return {
"schema_version": 1,
"provider": "openrouter",
"model": "~typesafe/jev-latest",
"latency_ms": 91,
"input_tokens": 12,
"output_tokens": 18,
"cost_usd": None,
"turn_seq": seq,
"policy": {
"version": "jev-affect-v1",
"min_confidence": 0.65,
"accepted_alpha": 0.35,
"accepted_cap": 0.15,
"tentative_alpha": 0.15,
"tentative_cap": 0.075,
"tentative_confidence_floor": 0.35,
"adjacent_probability_threshold": 0.8,
},
"context": {
"stage": "탐색",
"resistance": 0.4,
"effective_openness": 0.6,
"rapport_credit": 0.2,
},
"dimensions": dimensions,
}
def _admin_principal() -> Principal:
return Principal(user_id=ADMIN_ID, role=Role.ADMIN)
class AdminAffectStoreTest(unittest.IsolatedAsyncioTestCase):
async def test_list_uses_admin_scope_and_excludes_learner_identity(self) -> None:
case = self
class Conn:
async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
case.assertIn("FROM app.sessions", query)
case.assertNotIn("learner", query.lower())
return {"total": 1}
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
case.assertIn("app.client_affect_trace", query)
case.assertNotIn("learner_id", query)
case.assertNotIn("app.app_user", query)
case.assertNotIn(" text", query.lower())
case.assertEqual(args, (30, 0))
return [
{
"session_id": SESSION_ID,
"persona_code": "P4",
"started_at": OBSERVED_AT,
"ended": False,
"trace_count": 2,
}
]
runtime = AdminAffectRuntimeResponse(
enabled=True,
provider="openrouter",
model="~typesafe/jev-latest",
configured=True,
)
with (
patch.object(admin_affect, "acquire", return_value=_Acquire(Conn())) as acquire,
patch.object(admin_affect, "runtime_snapshot", return_value=runtime),
):
response = await admin_affect.list_sessions(
user_id=ADMIN_ID,
limit=30,
offset=0,
)
acquire.assert_called_once_with(role="admin", user_id=ADMIN_ID)
self.assertEqual(response.total, 1)
self.assertEqual(response.sessions[0].session_id, str(SESSION_ID))
self.assertEqual(response.sessions[0].trace_count, 2)
async def test_list_preserves_total_beyond_the_last_page(self) -> None:
case = self
class Conn:
async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
return {"total": 4}
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
case.assertEqual(args, (30, 30))
return []
with patch.object(admin_affect, "acquire", return_value=_Acquire(Conn())):
response = await admin_affect.list_sessions(
user_id=ADMIN_ID,
limit=30,
offset=30,
)
self.assertEqual(response.total, 4)
self.assertEqual(response.sessions, [])
async def test_detail_returns_actual_snapshot_and_ascending_page(self) -> None:
case = self
class Conn:
async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
if "FROM app.sessions AS s" in query:
case.assertNotIn("learner", query.lower())
case.assertNotIn("text", query.lower())
return {
"session_id": SESSION_ID,
"persona_code": "P4",
"affect_state": {
"emotion_anxiety": 0.6,
"emotion_hope": 0.2,
"emotion_anger": 1.5,
},
}
case.assertIn("count(*)", query)
return {"total_traces": 4}
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
case.assertIn("turn.seq < $2", query)
case.assertIn("ORDER BY turn.seq DESC", query)
case.assertNotIn("turn.text", query)
case.assertEqual(args, (SESSION_ID, 9, 3))
return [
{"turn_id": TURN_ID, "seq": 8, "created_at": OBSERVED_AT, "trace": _trace(seq=8)},
{"turn_id": TURN_ID, "seq": 7, "created_at": OBSERVED_AT, "trace": _trace(seq=7)},
{"turn_id": TURN_ID, "seq": 6, "created_at": OBSERVED_AT, "trace": _trace(seq=6)},
]
with patch.object(admin_affect, "acquire", return_value=_Acquire(Conn())) as acquire:
response = await admin_affect.get_session_detail(
user_id=ADMIN_ID,
session_id=SESSION_ID,
limit=2,
before_seq=9,
)
acquire.assert_called_once_with(role="admin", user_id=ADMIN_ID)
self.assertEqual([record.seq for record in response.traces], [7, 8])
self.assertTrue(response.has_more)
self.assertEqual(response.total_traces, 4)
self.assertEqual(response.current_emotions["anxiety"], 0.6)
self.assertEqual(response.current_emotions["hope"], 0.2)
self.assertIsNone(response.current_emotions["sadness"])
self.assertIsNone(response.current_emotions["anger"])
async def test_detail_keeps_trace_free_legacy_session_observable(self) -> None:
class Conn:
async def fetchrow(self, query: str, *args: object) -> dict[str, object]:
if "FROM app.sessions AS s" in query:
return {
"session_id": SESSION_ID,
"persona_code": "P4",
"affect_state": {},
}
return {"total_traces": 0}
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
return []
with patch.object(admin_affect, "acquire", return_value=_Acquire(Conn())):
response = await admin_affect.get_session_detail(
user_id=ADMIN_ID,
session_id=SESSION_ID,
limit=100,
before_seq=None,
)
self.assertEqual(response.traces, [])
self.assertEqual(response.total_traces, 0)
self.assertFalse(response.has_more)
self.assertTrue(all(value is None for value in response.current_emotions.values()))
async def test_detail_maps_missing_session_and_database_failure_separately(self) -> None:
class MissingConn:
async def fetchrow(self, query: str, *args: object) -> None:
return None
with patch.object(admin_affect, "acquire", return_value=_Acquire(MissingConn())):
with self.assertRaises(admin_affect.AdminAffectSessionNotFoundError):
await admin_affect.get_session_detail(
user_id=ADMIN_ID,
session_id=SESSION_ID,
limit=100,
before_seq=None,
)
with patch.object(admin_affect, "acquire", side_effect=RuntimeError("database down")):
with self.assertRaises(admin_affect.AdminAffectPersistenceError):
await admin_affect.list_sessions(user_id=ADMIN_ID, limit=30, offset=0)
class AdminAffectHttpBoundaryTest(unittest.TestCase):
def _client(self, principal: Principal) -> TestClient:
app = FastAPI()
app.include_router(admin_routes.router)
app.dependency_overrides[get_current_principal] = lambda: principal
return TestClient(app)
def test_non_admin_is_rejected_before_service_access(self) -> None:
principal = Principal(user_id="learner-1", role=Role.LEARNER)
with patch.object(admin_affect, "list_sessions", AsyncMock()) as list_sessions:
response = self._client(principal).get("/admin/affect/sessions")
self.assertEqual(response.status_code, 403)
list_sessions.assert_not_awaited()
def test_invalid_uuid_and_pagination_are_validation_errors(self) -> None:
client = self._client(_admin_principal())
self.assertEqual(client.get("/admin/affect/sessions?limit=0").status_code, 422)
self.assertEqual(
client.get("/admin/affect/sessions/not-a-uuid").status_code,
422,
)
self.assertEqual(
client.get(f"/admin/affect/sessions/{SESSION_ID}?before_seq=0").status_code,
422,
)
def test_route_maps_persistence_error_to_503(self) -> None:
with patch.object(
admin_affect,
"list_sessions",
AsyncMock(side_effect=admin_affect.AdminAffectPersistenceError("database down")),
):
response = self._client(_admin_principal()).get("/admin/affect/sessions")
self.assertEqual(response.status_code, 503)
def test_route_maps_missing_session_to_404(self) -> None:
with patch.object(
admin_affect,
"get_session_detail",
AsyncMock(side_effect=admin_affect.AdminAffectSessionNotFoundError("missing")),
):
response = self._client(_admin_principal()).get(
f"/admin/affect/sessions/{SESSION_ID}"
)
self.assertEqual(response.status_code, 404)
def test_route_serializes_list_response(self) -> None:
result = AdminAffectSessionListResponse(
runtime=AdminAffectRuntimeResponse(
enabled=True,
provider="openrouter",
model="~typesafe/jev-latest",
configured=True,
),
sessions=[],
total=0,
limit=30,
offset=0,
)
with patch.object(admin_affect, "list_sessions", AsyncMock(return_value=result)) as list_sessions:
response = self._client(_admin_principal()).get("/admin/affect/sessions")
self.assertEqual(response.status_code, 200)
self.assertEqual(response.json()["runtime"]["provider"], "openrouter")
self.assertEqual(response.json()["sessions"], [])
list_sessions.assert_awaited_once_with(user_id=ADMIN_ID, limit=30, offset=0)