전수 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

@ -264,6 +264,13 @@ class TechniqueDistribution(BaseModel):
underused: list[str] = Field(default_factory=list) # 과소/미사용 군집
class TurnValencePoint(BaseModel):
"""deep-loop 턴별 내담자 정서가 — 축어록 seq(1-based) 지목 + v(1~+1)."""
seq: int # 마스킹 축어록의 1-based 턴 번호
v: float # 정서가(1 매우 부정 ~ +1 매우 긍정)
class SessionEvaluation(BaseModel):
"""deep-loop 회기말/단계전환 정밀 평가 결과.
@ -282,6 +289,9 @@ class SessionEvaluation(BaseModel):
supervisor_rationale: Optional[str] = None # CommentKind.RATIONALE 종합
supervisor_critique: Optional[str] = None # CommentKind.CRITIQUE 종합
alternative_utterances: list[str] = Field(default_factory=list) # 대안 발화 제시
turn_valence: list[TurnValencePoint] = Field(
default_factory=list
) # 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천)
theory_mode: Optional[str] = None
error: Optional[str] = None
@ -378,6 +388,19 @@ def _deep_schema() -> dict[str, Any]:
"supervisor_rationale": {"type": "string"},
"supervisor_critique": {"type": "string"},
"alternative_utterances": {"type": "array", "items": {"type": "string"}},
# 선택 필드 — 내담자 발화별 정서가(리뷰 감정 밸런스 차트 원천).
"turn_valence": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": False,
"properties": {
"seq": {"type": "integer", "minimum": 1},
"v": {"type": "number", "minimum": -1, "maximum": 1},
},
"required": ["seq", "v"],
},
},
},
"required": ["strengths", "improvements"],
}
@ -583,7 +606,10 @@ def build_deep_messages(
"- intent_deviations: '의도와 다른 부분' 전부 {dimension, expected, actual, severity}로.\n"
"- supervisor_rationale: 회기 전반에서 적절했던 개입의 근거(rationale) 종합.\n"
"- supervisor_critique: 과도/부족/평가적 시각 등 주의점(critique) 종합.\n"
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개."
"- alternative_utterances: 핵심 장면에 더 나은 대안 상담자 발화 1~3개.\n"
"- turn_valence: *내담자* 발화 각각의 정서가를 {seq, v}로 산출. seq 는 축어록의 "
"1-based 턴 번호, v 는 1(매우 부정)~+1(매우 긍정). 근거 없는 극단값을 피하고 "
"불확실하면 0 근처로."
),
]
)
@ -854,6 +880,18 @@ async def evaluate_session(
dev = _parse_intent_deviation(d)
if dev is not None:
base.intent_deviations.append(dev)
for item in payload.get("turn_valence") or []:
if not isinstance(item, dict):
continue
seq = item.get("seq")
v = item.get("v")
if not isinstance(seq, int) or isinstance(seq, bool) or seq < 1:
continue
if not isinstance(v, (int, float)) or isinstance(v, bool):
continue
base.turn_valence.append(
TurnValencePoint(seq=seq, v=max(-1.0, min(1.0, float(v))))
)
_evaluator_cache_put(cache_key, base.model_dump())
return base
@ -888,6 +926,7 @@ __all__ = [
"ClientStateRead",
"TurnEvaluation",
"TechniqueDistribution",
"TurnValencePoint",
"SessionEvaluation",
"aggregate_distribution",
"evaluate_turn",

View file

@ -46,6 +46,10 @@ _KOREAN_SURNAME_CHARS = (
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
_KOREAN_CONTEXTLESS_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{2,3}}"
# 인명 마지막 글자로 사실상 쓰이지 않는 용언 활용/명사화 꼬리 글자. 문맥 단서가
# 없는 인명 패턴이 "유의점은·방치되는·마무리했을·연결감과·표현함을·어지러움은"
# 같은 일반 단어를 이름으로 오탐해 평가 문장을 훼손하는 것을 막는다(정밀도 가드).
_KOREAN_NAME_TAIL_GUARD = "(?<![했됐되된될것듯점음움됨함감])"
_KOREAN_NAME_STOPWORDS = {
"연락",
"연락처",
@ -109,7 +113,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
"NAME",
re.compile(
r"(?P<prefix>(?:(?:저는|나는|제가|내가)\s*)?)"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX}){_KOREAN_NAME_TAIL_GUARD}"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요))"
r"(?=$|[\s,.;!?。])"
),
@ -128,7 +132,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME})"
rf"(?<![가-힣])(?P<value>{_KOREAN_CONTEXTLESS_NAME}){_KOREAN_NAME_TAIL_GUARD}"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),

View file

@ -431,7 +431,11 @@ async def _session_review_payload_and_recipients(
OR lower(u.email) = ANY($1::text[])
OR (
u.role = 'instructor'
AND ($2 = '' OR u.cohort = $2)
AND (
$2 = ''
OR string_to_array(COALESCE(u.cohort, ''), ',')
&& string_to_array($2, ',')
)
)
)
AND COALESCE((p.notifications->>'learner_progress')::boolean, true)