전수 E2E 순회·소유자 결정 6건 구현·디자인 감사 반영
- 전수 순회: 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차 노트.
This commit is contained in:
parent
0ae94499f2
commit
4511383cd9
55 changed files with 9333 additions and 573 deletions
287
apps/api/app/test_review_valence.py
Normal file
287
apps/api/app/test_review_valence.py
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
"""감정 밸런스 타임라인(리뷰 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()
|
||||
Loading…
Add table
Add a link
Reference in a new issue