"""감정 밸런스 타임라인(리뷰 valence 차트) 파생 로직 회귀 테스트.""" from __future__ import annotations import unittest from typing import Any from . import session_read_model from .engine_client import GenerateResponse from .services import evaluator, persona as persona_service, state_machine from .store import InProcSession, TurnRecord def _session(*, created_at: float = 1_000.0) -> InProcSession: card = persona_service.P1 return InProcSession( session_id="review-valence-session", case_id="review-valence-case", learner_id="00000000-0000-0000-0000-000000000201", persona_code=card.code, theory_mode="humanistic", persona=card, state=state_machine.SessionState( resistance=card.base_resistance(), ideation_stage=card.ideation_baseline(), ), created_at=created_at, ) def _turn( seq: int, speaker: str, created_at: float, evaluation: dict[str, Any] | None = None, ) -> TurnRecord: return TurnRecord( turn_seq=seq, speaker=speaker, stage="rapport", text=f"발화 {seq}", text_masked=f"발화 {seq}", created_at=created_at, evaluation=evaluation, ) class CounselorBaselineTest(unittest.TestCase): def test_cumulative_average_and_t_normalization(self) -> None: base = 1_000.0 turns = [ _turn(1, "counselor", base, {"rapport_signal": 0.5}), _turn(2, "client", base + 10.0), _turn(3, "counselor", base + 30.0, {"rapport_signal": -0.5}), _turn(4, "client", base + 40.0), _turn(5, "counselor", base + 60.0, {"rapport_signal": 0.5}), ] points = session_read_model.counselor_baseline_points( turns, first_turn_ts=base, duration_seconds=120 ) self.assertEqual(len(points), 3) self.assertAlmostEqual(points[0].v, 0.5) self.assertAlmostEqual(points[1].v, 0.0) self.assertAlmostEqual(points[2].v, 0.5 / 3.0) self.assertAlmostEqual(points[0].t, 0.0) self.assertAlmostEqual(points[1].t, 0.25) self.assertAlmostEqual(points[2].t, 0.5) def test_resamples_to_at_most_ten_points(self) -> None: base = 1_000.0 turns = [ _turn(i + 1, "counselor", base + i * 10.0, {"rapport_signal": 0.1}) for i in range(12) ] points = session_read_model.counselor_baseline_points( turns, first_turn_ts=base, duration_seconds=110 ) self.assertEqual(len(points), 10) # 균등 리샘플이어도 양 끝점은 유지된다. self.assertAlmostEqual(points[0].t, 0.0) self.assertAlmostEqual(points[-1].t, 1.0) # t 는 단조 증가·클램프 범위 내. for prev, cur in zip(points, points[1:]): self.assertLessEqual(prev.t, cur.t) for point in points: self.assertGreaterEqual(point.v, -1.0) self.assertLessEqual(point.v, 1.0) def test_fewer_than_two_points_returns_empty(self) -> None: base = 1_000.0 turns = [ _turn(1, "counselor", base, {"rapport_signal": 0.4}), _turn(2, "client", base + 10.0), ] self.assertEqual( session_read_model.counselor_baseline_points( turns, first_turn_ts=base, duration_seconds=60 ), [], ) class ClientValenceTest(unittest.TestCase): def test_prefers_payload_turn_valence(self) -> None: base = 1_000.0 turns = [ _turn(1, "counselor", base), _turn(2, "client", base + 30.0), _turn(3, "counselor", base + 60.0), _turn(4, "client", base + 90.0), ] payload = { "turn_valence": [ {"seq": 2, "v": 0.4}, {"seq": 4, "v": -0.6}, ] } points = session_read_model.client_valence_points( turns, payload, first_turn_ts=base, duration_seconds=120 ) self.assertEqual(len(points), 2) self.assertAlmostEqual(points[0].t, 0.25) self.assertAlmostEqual(points[0].v, 0.4) self.assertAlmostEqual(points[1].t, 0.75) self.assertAlmostEqual(points[1].v, -0.6) def test_fallback_derives_from_turn_evaluations(self) -> None: base = 1_000.0 negative_eval = { "client_state_read": [{"code": "defensive", "label_ko": "방어"}], "appropriateness": "warn", } positive_eval = { "client_state_read": [{"code": "defense_loosening", "label_ko": "방어 완화"}], "appropriateness": "pos", } turns = [ _turn(1, "counselor", base, negative_eval), _turn(2, "client", base + 30.0), _turn(3, "counselor", base + 60.0, positive_eval), _turn(4, "client", base + 90.0), ] points = session_read_model.client_valence_points( turns, {}, first_turn_ts=base, duration_seconds=120 ) self.assertEqual(len(points), 2) self.assertLess(points[0].v, 0.0) self.assertGreater(points[1].v, 0.0) for point in points: self.assertGreaterEqual(point.v, -1.0) self.assertLessEqual(point.v, 1.0) class BuildSessionReviewValenceTest(unittest.TestCase): def test_review_fills_valence_arrays_and_axis(self) -> None: base = 1_000.0 sess = _session(created_at=base) sess.ended = True sess.ended_at = base + 120.0 for i in range(4): counselor_eval = { "rapport_signal": 0.2 + i * 0.1, "client_state_read": [{"code": "affect_contact", "label_ko": "정서 접촉/표현"}], "appropriateness": "pos", } sess.turns.append( _turn(i * 2 + 1, "counselor", base + i * 30.0, counselor_eval) ) sess.turns.append(_turn(i * 2 + 2, "client", base + i * 30.0 + 15.0)) evaluation_record = { "status": "ready", "payload": { "strengths": ["감정 반영이 좋았습니다."], "improvements": [], "turn_valence": [ {"seq": 2, "v": -0.3}, {"seq": 4, "v": -0.1}, {"seq": 6, "v": 0.2}, {"seq": 8, "v": 0.5}, ], }, } review = session_read_model.build_session_review( session_read_model.SessionReviewReadInput( session=sess, evaluation_record=evaluation_record, evaluation_durable=True, now_ts=base + 200.0, ) ) self.assertEqual(len(review.clientValence), 4) self.assertEqual(len(review.counselorBaseline), 4) # payload turn_valence 를 그대로 쓴다. self.assertAlmostEqual(review.clientValence[0].v, -0.3) self.assertAlmostEqual(review.clientValence[-1].v, 0.5) # x축 라벨은 3~5개. self.assertGreaterEqual(len(review.valenceAxis), 3) self.assertLessEqual(len(review.valenceAxis), 5) self.assertEqual(review.valenceAxis[0], "0:00") def test_review_without_signals_keeps_empty_arrays(self) -> None: base = 1_000.0 sess = _session(created_at=base) sess.ended = True sess.ended_at = base + 60.0 sess.turns.append(_turn(1, "counselor", base)) sess.turns.append(_turn(2, "client", base + 10.0)) review = session_read_model.build_session_review( session_read_model.SessionReviewReadInput( session=sess, evaluation_record={"status": "ready", "payload": {}}, evaluation_durable=True, now_ts=base + 100.0, ) ) self.assertEqual(review.clientValence, []) self.assertEqual(review.counselorBaseline, []) # 포인트가 없으면 기존 2원소 axis 를 유지한다. self.assertEqual(len(review.valenceAxis), 2) class _DeepValenceEngine: """deep-loop 구조화 응답에 turn_valence 를 포함하는 가짜 엔진.""" def __init__(self) -> None: self.requests: list[Any] = [] async def generate(self, req: Any) -> GenerateResponse: self.requests.append(req) return GenerateResponse( text="", model="fake-deep", provider="fake-provider", structured={ "strengths": ["공감 표현"], "improvements": [], "turn_valence": [ {"seq": 2, "v": -0.4}, {"seq": 4, "v": 1.7}, # 범위 초과 → 클램프 {"seq": 0, "v": 0.2}, # 잘못된 seq → 버림 ], }, ) class DeepSchemaTurnValenceTest(unittest.IsolatedAsyncioTestCase): async def asyncSetUp(self) -> None: evaluator.clear_evaluator_semantic_cache() async def asyncTearDown(self) -> None: evaluator.clear_evaluator_semantic_cache() def test_deep_schema_declares_turn_valence(self) -> None: schema = evaluator._deep_schema() turn_valence = schema["properties"].get("turn_valence") self.assertIsNotNone(turn_valence) self.assertEqual(turn_valence["type"], "array") item_props = turn_valence["items"]["properties"] self.assertIn("seq", item_props) self.assertIn("v", item_props) # 선택 필드라 required 에는 없어야 한다. self.assertNotIn("turn_valence", schema["required"]) async def test_evaluate_session_parses_turn_valence(self) -> None: engine = _DeepValenceEngine() result = await evaluator.evaluate_session( session_id="review-valence-session", stage="라포", masked_turns=[ {"seq": 1, "speaker": "counselor", "text": "요즘 어떠세요?"}, {"seq": 2, "speaker": "client", "text": "잘 모르겠어요."}, ], engine=engine, # type: ignore[arg-type] ) self.assertIsNone(result.error) self.assertEqual(len(result.turn_valence), 2) self.assertEqual(result.turn_valence[0].seq, 2) self.assertAlmostEqual(result.turn_valence[0].v, -0.4) self.assertAlmostEqual(result.turn_valence[1].v, 1.0) # 클램프 # 영속 payload 경로(to_dict)에 turn_valence 가 실린다. payload = result.to_dict() self.assertIn("turn_valence", payload) self.assertEqual(payload["turn_valence"][0]["seq"], 2) if __name__ == "__main__": unittest.main()