관리자 감정 관측 기록과 조회 API 추가
This commit is contained in:
parent
d22cd9883d
commit
acb0d26338
16 changed files with 1445 additions and 18 deletions
346
apps/api/app/test_client_affect_trace.py
Normal file
346
apps/api/app/test_client_affect_trace.py
Normal file
|
|
@ -0,0 +1,346 @@
|
|||
"""관리자 전용 Jev 감정 trace와 원자 영속화 회귀."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import unittest
|
||||
from dataclasses import replace
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from pydantic import ValidationError
|
||||
|
||||
from . import session_persistence, turn_runtime
|
||||
from .contracts.client_affect import ClientAffectDimensionTraceV1, ClientAffectTraceV1
|
||||
from .services import client_affect, orchestrator, persona, state_machine
|
||||
from .services.jev_client import AppraisalResult, EMOTION_DIMENSIONS, EmotionEstimate
|
||||
from .store import InProcSession, TurnRecord
|
||||
|
||||
|
||||
def _appraisal(
|
||||
*,
|
||||
confidence: float = 0.5,
|
||||
probabilities: tuple[float, ...] | None = (0.0, 0.5, 0.5, 0.0, 0.0),
|
||||
) -> AppraisalResult:
|
||||
return AppraisalResult(
|
||||
emotions={
|
||||
dimension: EmotionEstimate(
|
||||
score=0.375,
|
||||
confidence=confidence,
|
||||
probabilities=probabilities,
|
||||
)
|
||||
for dimension in EMOTION_DIMENSIONS
|
||||
},
|
||||
model="jev-test",
|
||||
latency_ms=11,
|
||||
input_tokens=13,
|
||||
output_tokens=17,
|
||||
provider="typesafe",
|
||||
cost_usd=None,
|
||||
)
|
||||
|
||||
|
||||
def _trace() -> ClientAffectTraceV1:
|
||||
appraisal = _appraisal()
|
||||
return _trace_from_appraisal(appraisal)
|
||||
|
||||
|
||||
def _trace_from_appraisal(appraisal: AppraisalResult) -> ClientAffectTraceV1:
|
||||
before = {f"emotion_{dimension}": 0.5 for dimension in EMOTION_DIMENSIONS}
|
||||
transition = client_affect.transition_emotions(
|
||||
before,
|
||||
{},
|
||||
appraisal,
|
||||
min_confidence=0.65,
|
||||
)
|
||||
return client_affect.build_client_affect_trace(
|
||||
affect_state_before=before,
|
||||
affect_baseline={},
|
||||
affect_state_after=transition.affect_state,
|
||||
appraisal=appraisal,
|
||||
transition=transition,
|
||||
turn_seq=1,
|
||||
stage="라포",
|
||||
resistance=0.65,
|
||||
effective_openness=0.15,
|
||||
rapport_credit=1.25,
|
||||
min_confidence=0.65,
|
||||
)
|
||||
|
||||
|
||||
class ClientAffectTraceContractTest(unittest.TestCase):
|
||||
def test_trace_preserves_transition_values_and_tentative_is_not_accepted(self) -> None:
|
||||
trace = _trace()
|
||||
|
||||
self.assertEqual(trace.schema_version, 1)
|
||||
self.assertEqual(trace.context.rapport_credit, 1.25)
|
||||
self.assertEqual(
|
||||
tuple(dimension.key for dimension in trace.dimensions),
|
||||
EMOTION_DIMENSIONS,
|
||||
)
|
||||
self.assertTrue(
|
||||
all(dimension.decision == "tentative" for dimension in trace.dimensions)
|
||||
)
|
||||
self.assertEqual(trace.dimensions[0].before, 0.5)
|
||||
self.assertEqual(trace.dimensions[0].target, 0.375)
|
||||
self.assertEqual(trace.dimensions[0].after, 0.48125)
|
||||
self.assertEqual(trace.dimensions[0].probabilities, (0.0, 0.5, 0.5, 0.0, 0.0))
|
||||
|
||||
def test_dimension_contract_rejects_nonfinite_probability(self) -> None:
|
||||
for invalid in (math.nan, math.inf):
|
||||
with self.subTest(invalid=invalid), self.assertRaises(ValidationError):
|
||||
ClientAffectDimensionTraceV1(
|
||||
key="anxiety",
|
||||
before=0.0,
|
||||
target=None,
|
||||
after=0.0,
|
||||
confidence=None,
|
||||
probabilities=(0.0, invalid, 0.0, 0.0, 1.0),
|
||||
decision="held",
|
||||
)
|
||||
|
||||
def test_accepted_and_held_trace_values_preserve_nullable_inputs(self) -> None:
|
||||
accepted = _trace_from_appraisal(
|
||||
_appraisal(
|
||||
confidence=0.9,
|
||||
probabilities=(0.0, 0.0, 0.4, 0.6, 0.0),
|
||||
)
|
||||
)
|
||||
held_appraisal = AppraisalResult(
|
||||
emotions={
|
||||
dimension: EmotionEstimate(
|
||||
score=math.nan,
|
||||
confidence=None,
|
||||
probabilities=None,
|
||||
)
|
||||
for dimension in EMOTION_DIMENSIONS
|
||||
},
|
||||
model="jev-test",
|
||||
latency_ms=11,
|
||||
input_tokens=13,
|
||||
output_tokens=17,
|
||||
provider="typesafe",
|
||||
cost_usd=None,
|
||||
)
|
||||
held = _trace_from_appraisal(held_appraisal)
|
||||
|
||||
self.assertTrue(all(item.decision == "accepted" for item in accepted.dimensions))
|
||||
self.assertEqual(
|
||||
accepted.dimensions[0].probabilities,
|
||||
(0.0, 0.0, 0.4, 0.6, 0.0),
|
||||
)
|
||||
self.assertEqual(accepted.dimensions[0].target, 0.375)
|
||||
self.assertEqual(accepted.dimensions[0].confidence, 0.9)
|
||||
self.assertTrue(all(item.decision == "held" for item in held.dimensions))
|
||||
self.assertTrue(
|
||||
all(
|
||||
item.target is None
|
||||
and item.confidence is None
|
||||
and item.probabilities is None
|
||||
for item in held.dimensions
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _Transaction:
|
||||
def __init__(self) -> None:
|
||||
self.error: type[BaseException] | None = None
|
||||
|
||||
async def __aenter__(self) -> None:
|
||||
return None
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
self.error = exc_type
|
||||
return False
|
||||
|
||||
|
||||
class _Connection:
|
||||
def __init__(self, *, fail_trace_insert: bool = False) -> None:
|
||||
self.fail_trace_insert = fail_trace_insert
|
||||
self.transaction_context = _Transaction()
|
||||
self.executed: list[str] = []
|
||||
|
||||
def transaction(self) -> _Transaction:
|
||||
return self.transaction_context
|
||||
|
||||
async def fetchval(self, query: str, *args: object) -> object:
|
||||
if "FROM app.sessions" in query:
|
||||
return "00000000-0000-0000-0000-000000000111"
|
||||
if "COALESCE(MAX(seq)" in query:
|
||||
return 2
|
||||
if "INSERT INTO app.turns" in query:
|
||||
return "00000000-0000-0000-0000-000000000222"
|
||||
raise AssertionError(f"unexpected query: {query}")
|
||||
|
||||
async def execute(self, query: str, *args: object) -> str:
|
||||
self.executed.append(query)
|
||||
if self.fail_trace_insert and "INSERT INTO app.client_affect_trace" in query:
|
||||
raise RuntimeError("trace insert failed")
|
||||
return "INSERT 0 1"
|
||||
|
||||
|
||||
class _Acquire:
|
||||
def __init__(self, conn: _Connection) -> None:
|
||||
self.conn = conn
|
||||
|
||||
async def __aenter__(self) -> _Connection:
|
||||
return self.conn
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
class ClientAffectTracePersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||
async def test_atomic_write_assigns_turn_id_only_after_trace_and_state_write(self) -> None:
|
||||
conn = _Connection()
|
||||
turn = TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="client",
|
||||
stage="라포",
|
||||
text="조금 더 이야기해볼게요.",
|
||||
text_masked="조금 더 이야기해볼게요.",
|
||||
)
|
||||
state = state_machine.SessionState(turn_seq=1)
|
||||
|
||||
with (
|
||||
patch.object(session_persistence, "get_pool", return_value=object()),
|
||||
patch.object(session_persistence, "acquire", return_value=_Acquire(conn)),
|
||||
):
|
||||
stored = await session_persistence.append_client_turn_with_affect_trace(
|
||||
session_id="00000000-0000-0000-0000-000000000111",
|
||||
learner_id="00000000-0000-0000-0000-000000000101",
|
||||
turn=turn,
|
||||
state=state,
|
||||
trace=_trace(),
|
||||
)
|
||||
|
||||
self.assertTrue(stored)
|
||||
self.assertEqual(turn.turn_id, "00000000-0000-0000-0000-000000000222")
|
||||
self.assertIsNone(conn.transaction_context.error)
|
||||
self.assertIn("INSERT INTO app.client_affect_trace", conn.executed[0])
|
||||
self.assertIn("INSERT INTO app.session_state", conn.executed[1])
|
||||
|
||||
async def test_atomic_write_keeps_turn_identifier_unpublished_when_trace_insert_fails(self) -> None:
|
||||
conn = _Connection(fail_trace_insert=True)
|
||||
turn = TurnRecord(
|
||||
turn_seq=1,
|
||||
speaker="client",
|
||||
stage="라포",
|
||||
text="조금 더 이야기해볼게요.",
|
||||
text_masked="조금 더 이야기해볼게요.",
|
||||
)
|
||||
|
||||
with (
|
||||
patch.object(session_persistence, "get_pool", return_value=object()),
|
||||
patch.object(session_persistence, "acquire", return_value=_Acquire(conn)),
|
||||
):
|
||||
with self.assertRaises(session_persistence.ClientAffectTracePersistenceError):
|
||||
await session_persistence.append_client_turn_with_affect_trace(
|
||||
session_id="00000000-0000-0000-0000-000000000111",
|
||||
learner_id="00000000-0000-0000-0000-000000000101",
|
||||
turn=turn,
|
||||
state=state_machine.SessionState(turn_seq=1),
|
||||
trace=_trace(),
|
||||
)
|
||||
|
||||
self.assertIsNone(turn.turn_id)
|
||||
self.assertIs(conn.transaction_context.error, RuntimeError)
|
||||
|
||||
|
||||
class ClientAffectTraceRuntimeTest(unittest.IsolatedAsyncioTestCase):
|
||||
def _session_and_result(
|
||||
self,
|
||||
) -> tuple[InProcSession, orchestrator.TurnContext, orchestrator.TurnResult]:
|
||||
state_before = state_machine.SessionState()
|
||||
state_after = replace(state_before, turn_seq=1)
|
||||
sess = InProcSession(
|
||||
session_id="trace-runtime-session",
|
||||
case_id="trace-runtime-case",
|
||||
learner_id="00000000-0000-0000-0000-000000000101",
|
||||
persona_code=persona.P1.code,
|
||||
theory_mode="humanistic",
|
||||
persona=persona.P1,
|
||||
state=state_before,
|
||||
)
|
||||
ctx = orchestrator.TurnContext(
|
||||
session_id=sess.session_id,
|
||||
case_id=sess.case_id,
|
||||
persona=sess.persona,
|
||||
state_before=state_before,
|
||||
learner_text_raw="그 마음을 조금 더 들려주실 수 있을까요?",
|
||||
learner_text_masked="그 마음을 조금 더 들려주실 수 있을까요?",
|
||||
state_after=state_after,
|
||||
client_affect_trace=_trace(),
|
||||
)
|
||||
result = orchestrator.TurnResult(
|
||||
turn_seq=1,
|
||||
stage=state_after.stage.value,
|
||||
effective_openness=state_after.effective_openness,
|
||||
client_reply="조금 더 이야기해볼게요.",
|
||||
safety_flagged=False,
|
||||
state_after=state_after,
|
||||
)
|
||||
return sess, ctx, result
|
||||
|
||||
async def test_trace_path_updates_runtime_mirrors_only_after_atomic_success(self) -> None:
|
||||
sess, ctx, result = self._session_and_result()
|
||||
append_counselor = AsyncMock()
|
||||
append_atomic = AsyncMock(return_value=True)
|
||||
update_state = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(turn_runtime, "append_completed_turn", append_counselor),
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"append_client_turn_with_affect_trace",
|
||||
append_atomic,
|
||||
),
|
||||
patch.object(turn_runtime, "update_session_state", update_state),
|
||||
):
|
||||
await turn_runtime.record_completed_turn(
|
||||
sess,
|
||||
ctx,
|
||||
result,
|
||||
context_prefix="trace test",
|
||||
)
|
||||
|
||||
append_atomic.assert_awaited_once()
|
||||
append_counselor.assert_awaited_once()
|
||||
update_state.assert_not_awaited()
|
||||
self.assertIs(sess.state, result.state_after)
|
||||
self.assertEqual([turn.speaker for turn in sess.turns], ["client"])
|
||||
|
||||
async def test_trace_path_keeps_runtime_mirrors_unchanged_when_atomic_write_fails(self) -> None:
|
||||
sess, ctx, result = self._session_and_result()
|
||||
append_counselor = AsyncMock()
|
||||
update_state = AsyncMock()
|
||||
|
||||
with (
|
||||
patch.object(turn_runtime, "append_completed_turn", append_counselor),
|
||||
patch.object(
|
||||
session_persistence,
|
||||
"append_client_turn_with_affect_trace",
|
||||
AsyncMock(
|
||||
side_effect=session_persistence.ClientAffectTracePersistenceError(
|
||||
"atomic write failed"
|
||||
)
|
||||
),
|
||||
),
|
||||
patch.object(turn_runtime, "update_session_state", update_state),
|
||||
):
|
||||
with self.assertRaises(session_persistence.ClientAffectTracePersistenceError):
|
||||
await turn_runtime.record_completed_turn(
|
||||
sess,
|
||||
ctx,
|
||||
result,
|
||||
context_prefix="trace test",
|
||||
)
|
||||
|
||||
append_counselor.assert_awaited_once()
|
||||
update_state.assert_not_awaited()
|
||||
self.assertIs(sess.state, ctx.state_before)
|
||||
self.assertEqual(sess.turns, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue