상담자 발화 판정(A)·감정(B)·표현(C) 20문항 질문 세트, 감쇠 없는 이번 턴 반응과 비대칭 기분 전이, 개방도 게이트로 생성 지시를 만들고 ccd.coping_strategy 전달 누락을 고친다. trace v2와 고정 문구 속마음 요약(migration 24, AI 경로 차단 RLS)을 같은 트랜잭션에 저장하고 피드백 정책이 켜진 경우에만 done·TurnResponse·음성 reply·리뷰로 노출한다. 회기 화면 속마음 보기 토글, 리뷰 접힘 블록, 관리자 감정 관측 v2 표시를 추가한다.
482 lines
18 KiB
Python
482 lines
18 KiB
Python
"""관리자 감정 관측 조회의 권한·페이지·영속 경계 검증."""
|
|
|
|
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 _trace_v2(*, seq: int) -> dict[str, object]:
|
|
dimensions = []
|
|
reaction = []
|
|
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",
|
|
}
|
|
)
|
|
reaction.append({"key": key, "value": 0.6, "included": True})
|
|
return {
|
|
"schema_version": 2,
|
|
"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-v2",
|
|
"min_confidence": 0.65,
|
|
"tentative_confidence_floor": 0.35,
|
|
"adjacent_probability_threshold": 0.8,
|
|
"worsening_accepted_alpha": 0.35,
|
|
"worsening_accepted_cap": 0.15,
|
|
"worsening_tentative_alpha": 0.15,
|
|
"worsening_tentative_cap": 0.075,
|
|
"recovery_accepted_alpha": 0.20,
|
|
"recovery_accepted_cap": 0.08,
|
|
"recovery_tentative_alpha": 0.08,
|
|
"recovery_tentative_cap": 0.04,
|
|
},
|
|
"context": {
|
|
"stage": "탐색",
|
|
"resistance": 0.4,
|
|
"effective_openness": 0.6,
|
|
"rapport_credit": 0.2,
|
|
},
|
|
"dimensions": dimensions,
|
|
"appraisal": [
|
|
{
|
|
"key": "a_judged",
|
|
"kind": "noul",
|
|
"probability": 0.8,
|
|
"confidence": 0.7,
|
|
"decision": "true",
|
|
},
|
|
{
|
|
"key": "a_coping",
|
|
"kind": "choice",
|
|
"choice": "manageable",
|
|
"probabilities": {
|
|
"nothing_asked": 0.1,
|
|
"manageable": 0.7,
|
|
"stretch": 0.1,
|
|
"overwhelming": 0.1,
|
|
},
|
|
"confidence": 0.6,
|
|
"decision": "manageable",
|
|
},
|
|
],
|
|
"reaction": reaction,
|
|
"expression": {
|
|
"behavior": {
|
|
"choice": "disclose_more",
|
|
"probabilities": {"disclose_more": 0.7, "hold_core": 0.3},
|
|
"confidence": 0.6,
|
|
"decision": "disclose_more",
|
|
},
|
|
"gated_behavior": "disclose_more",
|
|
"gate_reason": None,
|
|
"stance": "engage",
|
|
"display": {
|
|
"choice": "as_felt",
|
|
"probabilities": {"as_felt": 0.7, "masked": 0.3},
|
|
"confidence": 0.6,
|
|
"decision": "as_felt",
|
|
},
|
|
"disclose_ready": {"probability": 0.7, "confidence": 0.6, "decision": "true"},
|
|
"hidden_gap": False,
|
|
},
|
|
"sore_spot_count": 1,
|
|
}
|
|
|
|
|
|
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_parses_mixed_v1_and_v2_trace_rows(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:
|
|
return {
|
|
"session_id": SESSION_ID,
|
|
"persona_code": "P4",
|
|
"affect_state": {"emotion_anxiety": 0.5},
|
|
}
|
|
return {"total_traces": 2}
|
|
|
|
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
|
|
return [
|
|
{"turn_id": TURN_ID, "seq": 5, "created_at": OBSERVED_AT, "trace": _trace_v2(seq=5)},
|
|
{"turn_id": TURN_ID, "seq": 4, "created_at": OBSERVED_AT, "trace": _trace(seq=4)},
|
|
]
|
|
|
|
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,
|
|
)
|
|
|
|
case.assertEqual([record.trace.schema_version for record in response.traces], [1, 2])
|
|
v2_record = response.traces[1]
|
|
case.assertEqual(v2_record.trace.sore_spot_count, 1)
|
|
case.assertEqual(v2_record.trace.expression.gated_behavior, "disclose_more")
|
|
|
|
async def test_detail_rejects_trace_with_unsupported_schema_version(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": 1}
|
|
|
|
async def fetch(self, query: str, *args: object) -> list[dict[str, object]]:
|
|
broken = _trace(seq=1)
|
|
broken["schema_version"] = 3
|
|
return [{"turn_id": TURN_ID, "seq": 1, "created_at": OBSERVED_AT, "trace": broken}]
|
|
|
|
with patch.object(admin_affect, "acquire", return_value=_Acquire(Conn())):
|
|
with self.assertRaises(admin_affect.AdminAffectPersistenceError):
|
|
await admin_affect.get_session_detail(
|
|
user_id=ADMIN_ID,
|
|
session_id=SESSION_ID,
|
|
limit=100,
|
|
before_seq=None,
|
|
)
|
|
|
|
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)
|