"""관리자 전용 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, CHOICE_QUESTION_IDS, ChoiceJudgment, EMOTION_DIMENSIONS, EmotionEstimate, NOUL_QUESTION_IDS, NoulJudgment, SORE_SPOT_QUESTION_ID, ) 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 }, noul_judgments={ question_id: NoulJudgment(probability=0.3, confidence=0.6) for question_id in NOUL_QUESTION_IDS }, choice_judgments={ question_id: ChoiceJudgment(choice="uncertain-fixture", probabilities={"uncertain-fixture": 1.0}) for question_id in CHOICE_QUESTION_IDS }, sore_spot_count=0, 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 }, noul_judgments={}, choice_judgments={}, sore_spot_count=0, 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 ) ) def _full_choice(codes: list[str], choice: str, *, confidence: float | None = 0.6) -> ChoiceJudgment: return ChoiceJudgment( choice=choice, probabilities={code: (1.0 if code == choice else 0.0) for code in codes}, confidence=confidence, ) _COPING_CODES = ["nothing_asked", "manageable", "stretch", "overwhelming"] _MOVE_CODES = [ "reflection", "validation", "open_question", "closed_question", "clarification", "confrontation", "interpretation", "advice", "information", "self_disclosure", "topic_shift", "other", ] _BEHAVIOR_CODES = [ "disclose_more", "stay_with_feeling", "hold_core", "ask_back", "minimal_response", "shift_topic", "abstract_talk", "appease", "self_blame", "complain", "argue_back", "take_control", ] _DISPLAY_CODES = ["as_felt", "softened", "covered_by_agreement", "masked"] def _v2_appraisal( *, score: float = 0.5, confidence: float | None = 0.9, sore_spot_count: int = 1, ) -> AppraisalResult: return AppraisalResult( emotions={ dimension: EmotionEstimate(score=score, confidence=confidence) for dimension in EMOTION_DIMENSIONS }, noul_judgments={ question_id: NoulJudgment(probability=0.3, confidence=0.7) for question_id in NOUL_QUESTION_IDS }, choice_judgments={ "a_coping": _full_choice(_COPING_CODES, "nothing_asked"), "a_move": _full_choice(_MOVE_CODES, "reflection"), SORE_SPOT_QUESTION_ID: _full_choice(["none", "spot_1"], "spot_1"), "c_behavior": _full_choice(_BEHAVIOR_CODES, "disclose_more"), "c_display": _full_choice(_DISPLAY_CODES, "as_felt"), }, sore_spot_count=sore_spot_count, model="jev-test", latency_ms=11, input_tokens=13, output_tokens=17, provider="typesafe", cost_usd=None, ) class TransitionMoodTest(unittest.TestCase): def test_confirmed_transition_uses_direction_based_coefficients(self) -> None: previous = { "emotion_anxiety": 0.1, # 부정, score>old → 악화 "emotion_trust": 0.1, # 긍정, score>old → 회복 "emotion_sadness": 0.95, # 부정, score None: previous = {f"emotion_{dimension}": 0.2 for dimension in EMOTION_DIMENSIONS} appraisal = _v2_appraisal(score=0.375, confidence=0.5) appraisal = AppraisalResult( emotions={ dimension: EmotionEstimate( score=0.375, confidence=0.5, probabilities=(0.0, 0.5, 0.5, 0.0, 0.0) ) for dimension in EMOTION_DIMENSIONS }, noul_judgments=appraisal.noul_judgments, choice_judgments=appraisal.choice_judgments, sore_spot_count=appraisal.sore_spot_count, model=appraisal.model, latency_ms=appraisal.latency_ms, input_tokens=appraisal.input_tokens, output_tokens=appraisal.output_tokens, provider=appraisal.provider, cost_usd=appraisal.cost_usd, ) result = client_affect.transition_mood(previous, {}, appraisal, min_confidence=0.65) self.assertAlmostEqual(result.affect_state["emotion_anxiety"], 0.22625) # 악화 잠정 self.assertAlmostEqual(result.affect_state["emotion_trust"], 0.214) # 회복 잠정 self.assertEqual(set(result.tentative_dimensions), set(EMOTION_DIMENSIONS)) class ClientAffectTraceV2ContractTest(unittest.TestCase): def _trace_v2(self) -> "client_affect.ClientAffectTraceV2": # type: ignore[name-defined] appraisal = _v2_appraisal(score=0.9, confidence=0.9) before = {f"emotion_{dimension}": 0.1 for dimension in EMOTION_DIMENSIONS} transition = client_affect.transition_mood(before, {}, appraisal, min_confidence=0.65) expression = client_affect.build_expression_plan(appraisal, effective_openness=0.5) return client_affect.build_client_affect_trace_v2( affect_state_before=before, affect_baseline={}, affect_state_after=transition.affect_state, appraisal=appraisal, transition=transition, expression=expression, turn_seq=4, stage="탐색", resistance=0.4, effective_openness=0.5, rapport_credit=0.6, min_confidence=0.65, ) def test_trace_v2_has_schema_version_two_and_v1_shaped_dimensions(self) -> None: trace = self._trace_v2() self.assertEqual(trace.schema_version, 2) self.assertEqual(trace.policy.version, "jev-affect-v2") self.assertEqual( tuple(dimension.key for dimension in trace.dimensions), EMOTION_DIMENSIONS, ) self.assertEqual( tuple(dimension.key for dimension in trace.reaction), EMOTION_DIMENSIONS, ) def test_trace_v2_appraisal_only_lists_sent_a_layer_questions(self) -> None: trace = self._trace_v2() keys = {entry.key for entry in trace.appraisal} self.assertEqual( keys, {"a_understood", "a_judged", "a_autonomy", "a_directionless", "a_fact_conflict", "a_coping", "a_move", SORE_SPOT_QUESTION_ID}, ) self.assertNotIn("c_behavior", keys) self.assertNotIn("c_display", keys) self.assertNotIn("c_disclose_ready", keys) def test_trace_v2_expression_carries_gate_and_hidden_gap(self) -> None: trace = self._trace_v2() self.assertEqual(trace.expression.behavior.choice, "disclose_more") self.assertEqual(trace.expression.gated_behavior, "disclose_more") self.assertIsNone(trace.expression.gate_reason) self.assertEqual(trace.expression.display.choice, "as_felt") self.assertIsInstance(trace.expression.hidden_gap, bool) self.assertEqual(trace.sore_spot_count, 1) def test_trace_v2_reaction_marks_included_only_when_confidence_at_least_035(self) -> None: trace = self._trace_v2() self.assertTrue(all(entry.included for entry in trace.reaction)) self.assertTrue(all(entry.value is not None for entry in trace.reaction)) 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()