전수 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:
Yun Chan 2026-07-27 14:25:24 +09:00
parent 0ae94499f2
commit 4511383cd9
55 changed files with 9333 additions and 573 deletions

View file

@ -750,6 +750,24 @@ def _client_name(raw: str) -> str:
return name or raw.strip() or "내담자"
_DIFFICULTY_KO = {"easy": "기초", "moderate": "중간", "hard": "고난도"}
def _persona_label(code: str, difficulty: str) -> str:
return f"{code} · {_DIFFICULTY_KO.get(difficulty, difficulty)}"
def _josa_wa_gwa(name: str) -> str:
"""이름 마지막 글자의 받침 유무로 와/과를 고른다. 한글이 아니면 병기한다."""
tail = name[-1] if name else ""
if "" <= tail <= "":
has_final = (ord(tail) - 0xAC00) % 28 != 0
if has_final:
return ""
return ""
return "와(과)"
def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[ReviewTurn]) -> str:
if not turns:
return (
@ -760,7 +778,7 @@ def _review_summary(*, client_name: str, reached_phase: StageLabel, turns: list[
client_count = sum(1 for turn in turns if turn.speaker == "client")
return (
f"이 리뷰는 현재 세션에 저장된 실제 축어록 {len(turns)}개를 기반으로 합니다. "
f"{client_name}의 회기는 {reached_phase} 단계까지 진행되었고, "
f"{client_name}{_josa_wa_gwa(client_name)}의 회기는 {reached_phase} 단계까지 진행되었고, "
f"학습자 발화 {learner_count}개와 내담자 응답 {client_count}개가 기록되었습니다. "
"평가 AI 또는 교수자 코멘트가 아직 생성되지 않은 항목은 빈 상태로 남겨 둡니다."
)
@ -1405,6 +1423,185 @@ def _review_nonverbal_events(turn: TurnRecord) -> list[ReviewNonverbalEvent]:
return events
# ─ 감정 밸런스 타임라인(valence) 파생 — 순수 함수 ──────────────────────────────
_VALENCE_MAX_POINTS = 10
# client_state_read 코드 → 정서가 극성(taxonomy.ClientState 코드 기준 휴리스틱 맵).
_CLIENT_STATE_VALENCE: dict[str, float] = {
# 음의 극성 — 방어·위축·위기 신호
"involuntary": -0.5,
"defensive": -0.7,
"suicidal_ideation_admit": -0.9,
"negative_self_perception": -0.7,
"conflicted": -0.4,
"lack_of_confidence": -0.4,
"compliant_surface": -0.2,
"externalizing": -0.3,
"apparent_competence": -0.2,
"active_passivity": -0.3,
"self_harm_disclosure": -0.9,
"somatic_complaint": -0.4,
"affect_masking": -0.3,
"focus_drift_fusion": -0.3,
# 양의 극성 — 개방·접촉·진전 신호
"affect_contact": 0.6,
"thought_organizing": 0.5,
"responds_to_exploration": 0.5,
"expresses_plan": 0.7,
"defense_loosening": 0.6,
"seeks_guidance": 0.2,
}
def _clamp_valence(value: float) -> float:
return max(-1.0, min(1.0, value))
def _valence_t(
created_at: float, first_turn_ts: float, duration_seconds: float
) -> float:
"""턴 시각 → 회기 진행률(0~1 클램프)."""
if duration_seconds <= 0:
return 0.0
return max(0.0, min(1.0, (created_at - first_turn_ts) / duration_seconds))
def _finalize_valence_points(
points: list[ReviewValencePoint],
) -> list[ReviewValencePoint]:
"""2개 미만이면 빈 배열(차트 빈 상태), 10개 초과면 균등 리샘플(양 끝점 유지)."""
if len(points) < 2:
return []
if len(points) <= _VALENCE_MAX_POINTS:
return points
last = len(points) - 1
indices: list[int] = []
for i in range(_VALENCE_MAX_POINTS):
idx = round(i * last / (_VALENCE_MAX_POINTS - 1))
if not indices or idx != indices[-1]:
indices.append(idx)
return [points[i] for i in indices]
def counselor_baseline_points(
turns: list[TurnRecord],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""학습자 턴 rapport_signal 누적 이동평균 → 상담자 기준선 궤적."""
points: list[ReviewValencePoint] = []
total = 0.0
count = 0
for turn in turns:
if turn.speaker != "counselor":
continue
ev = session_metrics.turn_eval(turn)
if ev is None or str(ev.get("error") or "").strip():
continue
rapport = session_metrics.turn_rapport(ev)
if rapport is None:
continue
count += 1
total += rapport
points.append(
ReviewValencePoint(
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
v=_clamp_valence(total / count),
)
)
return _finalize_valence_points(points)
def _fallback_client_valence_points(
turns: list[TurnRecord],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""폴백 — 상담자 턴 평가의 client_state_read 극성과 appropriateness(0~1) 결합."""
points: list[ReviewValencePoint] = []
for turn in turns:
if turn.speaker != "counselor":
continue
ev = session_metrics.turn_eval(turn)
if ev is None or str(ev.get("error") or "").strip():
continue
polarities: list[float] = []
for state in ev.get("client_state_read") or []:
code = state.get("code") if isinstance(state, dict) else state
mapped = _CLIENT_STATE_VALENCE.get(str(code or "").strip())
if mapped is not None:
polarities.append(mapped)
score01 = session_metrics.turn_score(ev)
parts: list[float] = []
if polarities:
parts.append(0.7 * (sum(polarities) / len(polarities)))
if score01 is not None:
parts.append(0.3 * (score01 * 2.0 - 1.0))
if not parts:
continue
points.append(
ReviewValencePoint(
t=_valence_t(turn.created_at, first_turn_ts, duration_seconds),
v=_clamp_valence(sum(parts)),
)
)
return points
def client_valence_points(
turns: list[TurnRecord],
evaluation_payload: dict[str, object],
*,
first_turn_ts: float,
duration_seconds: float,
) -> list[ReviewValencePoint]:
"""내담자 정서가 궤적 — deep 평가 turn_valence 우선, 없으면 턴 평가 기반 폴백."""
raw = (
evaluation_payload.get("turn_valence")
if isinstance(evaluation_payload, dict)
else None
)
points: list[ReviewValencePoint] = []
if isinstance(raw, list):
for item in raw:
if not isinstance(item, dict):
continue
seq = item.get("seq")
v = item.get("v")
if isinstance(seq, bool) or not isinstance(seq, int):
continue
if isinstance(v, bool) or not isinstance(v, (int, float)):
continue
index = seq - 1
if index < 0 or index >= len(turns):
continue
points.append(
ReviewValencePoint(
t=_valence_t(
turns[index].created_at, first_turn_ts, duration_seconds
),
v=_clamp_valence(float(v)),
)
)
points.sort(key=lambda point: point.t)
if not points:
points = _fallback_client_valence_points(
turns, first_turn_ts=first_turn_ts, duration_seconds=duration_seconds
)
return _finalize_valence_points(points)
def _valence_axis(
duration_seconds: int, *, has_points: bool, fallback: list[str]
) -> list[str]:
"""포인트가 있으면 시간 라벨 4개(0~회기말 균등), 없으면 기존 axis 유지."""
if not has_points or duration_seconds <= 0:
return fallback
return [_offset_label(duration_seconds * i / 3) for i in range(4)]
def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewResponse:
sess = read_input.session
visible_turns = learner_visible_turns(sess)
@ -1455,6 +1652,27 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
)
)
# 감정 밸런스 타임라인 — 학습자 기준선 + 내담자 정서가(비공개 턴 존재 시 비산출)
counselor_baseline: list[ReviewValencePoint] = []
client_valence: list[ReviewValencePoint] = []
if not hidden_turns:
counselor_baseline = counselor_baseline_points(
visible_turns,
first_turn_ts=first_turn_ts,
duration_seconds=duration_seconds,
)
client_valence = client_valence_points(
visible_turns,
evaluation_payload,
first_turn_ts=first_turn_ts,
duration_seconds=duration_seconds,
)
valence_axis = _valence_axis(
duration_seconds,
has_points=bool(counselor_baseline or client_valence),
fallback=axis,
)
if not turns:
session_signal = "기록 없음"
elif sess.ended:
@ -1533,7 +1751,7 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
client=ReviewClient(
name=client_name,
initial=client_initial,
persona=f"{sess.persona_code} · {sess.persona.difficulty}",
persona=_persona_label(sess.persona_code, str(sess.persona.difficulty)),
),
date=datetime.fromtimestamp(sess.created_at).strftime("%Y-%m-%d"),
durationLabel=_duration_label(duration_seconds),
@ -1545,9 +1763,9 @@ def build_session_review(read_input: SessionReviewReadInput) -> SessionReviewRes
summary=summary,
phases=_phase_segments(stage_labels),
phaseAxis=axis,
valenceAxis=axis,
clientValence=[],
counselorBaseline=[],
valenceAxis=valence_axis,
clientValence=client_valence,
counselorBaseline=counselor_baseline,
turns=turns,
rubric=rubric,
goodMoments=good_moments,