전수 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
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue