vignette/apps/api/app/test_client_affect_trace.py

346 lines
12 KiB
Python

"""관리자 전용 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()