- 전수 순회: IA 전 라우트 412개 기능 인벤토리를 체크리스트 백로그로 관리 (docs/ops/e2e-full-sweep-2026-07-27.md), 신규 full-sweep 스펙 10파일 추가. RED→GREEN으로 결함 12건 수정: 관리자 무한 렌더 프리즈(TanStack autoReset 루프), 복수 코호트 저장 유실, 페르소나 보관 503(SQL 컬럼 모호성), PII 과잉 마스킹, /admin/ai watchdog 오탐 오버레이, 모바일 겹침 2건, 설정 스크롤 스파이, 온보딩 전화번호 무검증, 리뷰 조사·난도 라벨, pending 피드백 등. - 소유자 결정 구현: 설정 아바타 변경, 동의 철회·재동의 전체 흐름, 신규 학습자 기초 우선 추천, 학생 분석 테이블 가상화(@tanstack/react-virtual), 저작 모드 죽은 레일 정리, 감정 밸런스 타임라인 차트(deep turn_valence + 결정론 파생 폴백). - 디자인 감사(142차): 라이트 팔레트 AA 대비, 다크 토큰 별칭 통일, 미정의 CSS 변수 정리, 한글 keep-all 전역화, 탭 타깃 24px, LCP preconnect. - 검증: npm run e2e 병렬 432 수집 GREEN + 직렬 49/49 exit 0, 백엔드 pytest 421, gateway 29, typecheck/build/design-ssot/dead-code/중복 게이트 통과, layout-visual-gate 15/15, session-layout 8/8. 상세는 SSOT 대시보드 142~144차 노트.
287 lines
11 KiB
Python
287 lines
11 KiB
Python
"""감정 밸런스 타임라인(리뷰 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()
|