외부 계정 수동 등록 허용

This commit is contained in:
Yun Chan 2026-06-28 11:31:07 +09:00
parent bd389a97cc
commit 8ed185ce6c
9 changed files with 3450 additions and 1484 deletions

View file

@ -281,15 +281,16 @@
<section class="pulse">
<div class="pulse-main">
<span class="eyebrow">project pulse</span>
<h1>지금 위치: <em>Phase 0 마감 직전</em> → Phase 1 진입 대기.<br><em>위기게이트 109</em><em>교수자 성장 UI</em>는 1차 구현 완료.</h1>
<p class="pulse-state">코어(저항·반응 조절, 상태머신, RAG, 이론모드, 가드레일, 평가 JSON Schema)는 <b>잘 정렬</b>됐고 회귀도 녹색이다. 막힌 건 두 종류 — <b>내가 실증/결정해야 풀리는 외부 의존</b>(아래 "내 차례")과, <b>남은 원천문서·콘텐츠·운영</b>이다. 이 판은 그 둘을 분리해서 보여준다.</p>
<h1>지금 위치: <em>Phase 0 마감 직전</em> → Phase 1 진입 대기.<br><em>학습자 개인화 대시보드</em><em>교수자 검토 상태</em>는 1차 구현·검증 완료.</h1>
<p class="pulse-state">코어(저항·반응 조절, 상태머신, RAG, 이론모드, 가드레일, 평가 JSON Schema)는 <b>잘 정렬</b>됐고, 이번 점검에서 학습자 홈·교수자 리뷰·공개/Tailnet 런타임 스모크도 녹색이다. 막힌 건 두 종류 — <b>실제 계정·외부 DNS·기관 결정이 있어야 풀리는 게이트</b>와, <b>임상 루브릭·콘텐츠·운영 증거처럼 아직 남은 제품</b>이다. 이 판은 그 둘을 분리해서 보여준다.</p>
<div class="phase-rail" id="phase-rail" aria-label="Phase 진행 추정"></div>
<div class="mstrip" aria-label="검증 지표">
<span class="mchip good">Full Playwright <b>72</b> 통과</span>
<span class="mchip good">Backend pytest <b>130</b> 통과</span>
<span class="mchip good">Full E2E <b>113</b> 통과</span>
<span class="mchip good">Backend focused <b>12</b> 통과</span>
<span class="mchip good">Layout 시각게이트 <b>7/7</b> accept</span>
<span class="mchip">Pages prod <b>12da49f3</b></span>
<span class="mchip">Tailnet <b>alpaca-home</b> OK</span>
<span class="mchip good">Public API <b>prod OK</b></span>
<span class="mchip crit">Compose env <b>BLOCK</b></span>
<span class="mchip crit">원천문서 갭 <b>critical 3</b></span>
<span class="mchip">Decision <b>P0 조건부</b></span>
</div>
@ -454,6 +455,23 @@
<p class="dg-note">6차 적용(2026-06-27): 설정 화면은 글로벌 셸을 숨기고 설정 전용 좌측 레일을 주 내비로 승격했다. 계정, AI 운영, 테마, 음성, 알림 카드를 시안의 차분한 설정 워크스페이스 흐름에 맞춰 단일 컬럼으로 재배치했고, 모바일은 상단 고정 섹션 탭으로 모든 항목이 잘리지 않게 정리했다.</p>
<p class="dg-note">7차 적용(2026-06-27): 로그인 화면은 별도 생성한 래스터 상담실 배경(<code>login-counseling-room.png</code>)을 왼쪽 브랜드 패널에 적용해 시안의 실제 상담실 분위기를 반영했다. 학습자 홈은 모바일 grid 행 계산 때문에 생기던 리스트-상세 간 큰 공백을 제거하고, 회기 준비 보드가 한 흐름으로 이어지게 정리했다.</p>
<p class="dg-note">8차 적용(2026-06-27): 다크 테마가 라이트 primitive 토큰과 역할 accent에 덮여 흰 섹션이 섞이던 문제를 수정했다. 앱 부트 시 테마를 먼저 적용하고, 다크 모드에서 <code>paper/surface/hair/ink</code> primitive까지 스왑한다. 상담 세션 진행 화면은 테마 토글과 무관하게 어두운 콘솔 패널로 고정했고, 드래그형 종료 제스처는 명시적 <code>회기 종료</code> 버튼 + 확인 다이얼로그로 교체했다. 회기 리뷰도 동일한 다크 카드/축어록/워크시트 재질로 보정했다.</p>
<p class="dg-note">9차 적용(2026-06-27): 관리자 콘솔 IA를 <code>/admin</code> 운영 홈과 <code>/admin/users</code> 사용자 관리, <code>/admin/access</code> 접근 권한, <code>/admin/tickets</code> 운영 티켓 메뉴로 분리했다. 운영 홈은 실제 API가 있는 교육 진행·온라인 사용자·헬스·AI 비용·서비스 리소스를 우선 표시하고, 티켓/업타임 이력은 <code>app.support_ticket</code>·<code>app.admin_health_event</code> 기반 1차 저장 모델로 연결했다. 세분 그룹 권한 enforcement는 아직 정책 화면이며 가짜 권한 효과를 표시하지 않는다.</p>
<p class="dg-note">17차 적용(2026-06-28): 운영 홈의 티켓 요약은 <code>resolved</code>/<code>closed</code> 이력을 진행 중 위험으로 세지 않는다. <code>high_priority_count</code>와 미니 티켓 목록은 미해결 티켓 기준이며, E2E 운영 티켓 테스트는 제목·본문에 테스트임을 명시하고 실패 시에도 cleanup patch로 해결 처리한다.</p>
<p class="dg-note">19차 적용(2026-06-28): <code>/admin/tickets</code>는 현재 범위를 미해결 큐 목록과 상태 변경으로 좁혔다. 정적 분류/인사이트 탭은 제거하고, 카드에 카테고리·신고자·경로·접수/갱신 시각을 표시한다. 해결된 항목은 최근 해결 이력으로 내려간다. 필터·카테고리 큐·Claude Recipe headless 자동 처리·이슈 등록은 백로그 <code>운영 티켓 자동 분류·처리 2차</code>로 남겼다.</p>
<p class="dg-note">20차 적용(2026-06-28): 신규 Google/SAML 사용자는 <code>account_status=pending</code>으로 시작하고 승인 전에는 <code>/pending</code> 안내 화면만 본다. <code>yunchan@twentyoz.kr</code>는 슈퍼 관리자 allowlist로 admin+approved를 받으며, <code>/admin/users</code>는 가입 승인 탭에서 pending 계정을 승인 또는 보류 처리한다.</p>
<p class="dg-note">21차 적용(2026-06-28): 관리자 페이지 진입권을 기본 역할과 분리해 <code>app_user.admin_access</code>로 저장한다. <code>AUTH_SUPER_ADMIN_EMAILS</code> 기본값은 <code>yunchan@twentyoz.kr</code>, <code>hoonjungkoo@hs.ac.kr</code>이며, 슈퍼 관리자는 학습자·교수자·관리자 공간 전환과 관리자 권한 부여/회수를 할 수 있다. 학생·교수 계정도 <code>admin_access=true</code>면 우측 상단 관리자 진입이 노출된다. 구성 슈퍼 관리자의 권한 회수와 계정 비활성화는 차단한다.</p>
<p class="dg-note">25차 적용(2026-06-28): <code>/admin/users</code>에서 허용 도메인 밖 이메일도 정확한 계정 단위로 강제 등록할 수 있다. Google/SAML/dev-login은 미리 등록된 이메일만 도메인 게이트 예외로 통과시키고, provider 로그인 시 기존 관리 row의 역할·코호트·승인 상태를 이어받는다. 미등록 외부 도메인 로그인은 계속 차단한다.</p>
<p class="dg-note">10차 적용(2026-06-27): 학습자 영역을 <code>/learn</code> 대시보드, <code>/learn/practice</code> 연습 대상, <code>/learn/history</code> 기록으로 분리했다. 홈은 최근/진행 회기를 기준으로 Live2D 아바타·감정 힌트·마지막 내담자 반응·학습자 마지막 발화·AI 코치 문장을 보여주는 세션 리캡 중심 대시보드로 바꾸고, 새 회기 시작은 학습 탭으로 낮췄다.</p>
<p class="dg-note">11차 적용(2026-06-27): 현재 <code>/learn</code> 화면을 다시 캡처한 뒤 UX 원칙(사용자 필요, progressive disclosure, 정보 향, adaptive layout)을 기준으로 이미지 생성 시안 3장을 만들었다. UI 지침에는 <code>11-learner-dashboard-redesign-concept-b.png</code>, <code>12-learner-dashboard-redesign-concept-b-alt.png</code>만 채택하고, 상담실 배경으로 나온 <code>10-learner-dashboard-redesign-concept-a.png</code>는 폐기했다. 구현은 회기 리캡과 AI 코치/이어하기 CTA를 홈 첫 블록으로 묶고, 독립 <code>다음 행동</code> 카드는 제거했다.</p>
<p class="dg-note">16차 적용(2026-06-28): 생성 시안과 더 맞추기 위해 <code>/learn</code> 하단을 <code>리뷰 대기</code>, <code>다음 연습 추천</code>, <code>최근 기록</code> 3분할 카드로 재배치했다. AI 코치 패널에서는 진행/리뷰 카운트와 보조 링크를 제거하고, 코칭 문장·3개 체크 힌트·단일 <code>이어하기</code> CTA만 남겼다. 최신 검증 캡처는 <code>docs/design-verification/learner-home-redesign/final-*.png</code>다.</p>
<p class="dg-note">18차 적용(2026-06-28): <code>/learn/history</code>는 표형 기존 회기 목록을 제거하고 <code>전체</code>, <code>진행 중</code>, <code>리뷰 필요</code>, <code>보관됨</code> 상태 필터와 검색, 카드형 회기 기록 작업대로 재구성했다. 설계 지침은 <code>docs/design-concepts/prompts/learner-history-archive-redesign-2026-06-28.md</code>, 검증 캡처는 <code>docs/design-verification/learner-history-redesign/history-*.png</code>다.</p>
<p class="dg-note">22차 적용(2026-06-28): 회기 아카이브를 실제 저장 상태로 연결했다. 학습자 종료 회기는 <code>POST /sessions/{id}/archive</code>/<code>restore</code>로 보관·복원하고, <code>app.session_archive_state</code>는 삭제가 아니라 학습자별 보기 상태만 저장한다. 회기·턴·리뷰·공유 링크·연구/감사 증거는 유지하며, 보관된 회기는 <code>/learn/history</code><code>보관됨</code> 필터와 dashboard <code>archived_sessions</code>에 반영되고 리뷰 대기 행동 큐에서는 제외된다.</p>
<p class="dg-note">24차 적용(2026-06-28): 운영 콘솔 <code>/admin/usage</code>의 DB 집계 쿼리에서 <code>ORDER BY tokens_in + tokens_out</code>가 PostgreSQL 원본 컬럼 참조로 해석되어 prod에서 503 fail-closed 되던 문제를 수정했다. provider/model별 정렬은 <code>SUM(tokens_in)+SUM(tokens_out)</code> 집계식 기준으로 바꿨고, public API 프로세스의 실제 HTTP smoke는 <code>source=database</code>, <code>durable=true</code>로 200을 반환한다.</p>
<p class="dg-note">23차 적용(2026-06-28): <code>app.persona_voice_map</code>을 실제 <code>/voice/ws</code> TTS voice 선택에 연결했다. 명시 query preset이 최우선이고, 없으면 세션의 <code>persona_id/persona_version</code> 또는 dev persona catalog의 voice map을 읽으며, unsupported provider는 기존 persona-code fallback으로 fail-safe 처리한다. seed materializer는 기본 OpenAI voice map row를 <code>ON CONFLICT DO NOTHING</code>으로 생성하고, dev 런타임 스키마 보강은 기존 DB의 <code>persona_voice_map</code> 누락도 함께 복구한다.</p>
<p class="dg-note">12차 적용(2026-06-27): 회기 리뷰 데스크톱 레이아웃은 sticky 요약 레일을 유지하되 트랜스크립트가 같은 컬럼을 공유하지 않도록 재배치했다. 왼쪽 요약 문장은 보조 브리핑 밀도로 낮추고, 축어록은 중앙 근거 작업대에 고정해 스크롤 중 레일이 발화 내용을 가리지 않게 했다.</p>
<p class="dg-note">13차 적용(2026-06-27): 회기 리뷰의 <code>적절한 개입</code> 등 평가 콜아웃은 제한 markdown을 기본 렌더링한다. <code>effective_openness</code><code>effective openness(유효 개방도)</code>로 학술 용어화하고, 상담자 질문 발화는 <code>ReviewNote.quote</code> 인용 블록으로 분리해 카드 내부에서 시각화한다.</p>
<p class="dg-note">14차 적용(2026-06-27): 활성 상담 세션은 시작 직후 실제 <code>session_id</code> URL로 고정해 새로고침 후에도 같은 회기 상세를 복원한다. 전역 topbar는 몰입형 화면에서 계속 숨기되, 내부 세션바에 <code>기록으로</code>/<code>학습 홈</code> 이동을 추가했다. 종료된 세션 상세도 곧바로 리뷰로 밀어내지 않고 읽기 전용 기록 화면에 머물며, 리뷰 이동은 명시 버튼으로만 수행한다.</p>
<p class="dg-note">15차 적용(2026-06-28): 학습자·교수자·운영·페르소나 스튜디오 화면을 같은 작업형 폭 정책으로 정렬했다. 교수 콘솔은 검토 대기·위기 알림·페르소나 검수·저작실 순서가 DOM과 화면 모두 일치하고, 페르소나 스튜디오는 SSOT 첨부/RAG 생성/항목 편집/검증 큐가 한 작업면에서 이어지는 구조로 유지한다. 적용 리포트와 대표 캡처는 <code>docs/ops/layout-research-2026-06-28/dashboard-layout-alignment-report.md</code>에 남겼다.</p>
<div class="dg-principles" aria-label="디자인 생성 가드레일">
<div><b>래스터만 사용</b><span>이미지 생성 도구 산출물은 PNG 기반 시안이다. SVG·벡터·와이어프레임·로고 시트로 해석하지 않는다.</span></div>
<div><b>기능 우선</b><span>메인 라우트의 실제 액션과 정보 구조를 먼저 반영한다. 장식은 기능을 가리지 않는 수준에서만 쓴다.</span></div>
@ -467,11 +485,11 @@
</thead>
<tbody>
<tr><td>로그인/진입</td><td>Google OAuth, 허용 도메인 안내, 로컬 역할 선택, 교육용 비치료 고지</td><td>신뢰·보안이 먼저 보이는 조용한 진입 화면</td></tr>
<tr><td>학습자 홈</td><td>내담자 선택, 주호소·난도·접근·음성 정보, 새 회기 시작, 기존 회기 이어가기</td><td>오늘 시작할 연습이 명확한 상담 연습 작업대</td></tr>
<tr><td>학습자 홈</td><td>학습 요약, 진행/리뷰 가능 회기, 마지막 세션 리캡, Live2D 감정 힌트, AI 코치, 학습·기록 드릴다운</td><td>오늘 이어갈 회기와 학습 상태가 먼저 보이는 개인 대시보드</td></tr>
<tr><td>상담 세션</td><td>회기 단계, 내담자 컨텍스트, 아바타, 음성 오브, 실시간 자막, 텍스트 폴백, 안전 점검</td><td>화면 중심은 내담자, 피드백은 조용한 신호</td></tr>
<tr><td>회기 리뷰</td><td>요약, 감정 밸런스, 회기 흐름, 축어록, 기법 태그, 개선점, 오디오/PDF</td><td>점수 폭격이 아니라 근거 기반 회고</td></tr>
<tr><td>교수 콘솔</td><td>리뷰 대기 큐, 코호트 현황, 페르소나 검수, 종료 회기 목록</td><td>awareness보다 triage에 가까운 교수자 작업대</td></tr>
<tr><td>관리자 콘솔</td><td>서비스 상태, 엔진·DB·음성·리뷰 리소스, 사용자 등록·수정·비활성화</td><td>시스템 건강성과 권한 관리가 한눈에 보이는 운영 화면</td></tr>
<tr><td>관리자 콘솔</td><td>운영 홈: 교육 진행·온라인 사용자·헬스 샘플 이력·AI 비용·운영 티켓 요약. 하위 메뉴: 사용자 관리, 접근 권한, 운영 티켓 처리 큐</td><td>상태 관측과 관리 작업을 분리한 실제 운영 콘솔</td></tr>
<tr><td>설정</td><td>계정, 관리자 AI 운영 설정, 테마, 음성 프리셋, 속도, 알림</td><td>역할별 설정만 보이는 조용한 환경 관리</td></tr>
<tr><td>이미지 요소</td><td>종이 질감, 상담실 책상, 노트, 식물, 부드러운 빛, 패턴, 아바타 분위기</td><td>UI 뒤에서만 느껴지는 따뜻함과 반구상 내담자 톤</td></tr>
</tbody>
@ -486,6 +504,14 @@
<img src="design-concepts/generated/02-learner-home-responsive.png" alt="학습자 홈 PC 태블릿 모바일 반응형 디자인 시안" loading="lazy">
<div class="dg-card-body"><b>학습자 홈</b><span>내담자 선택, 회기 시작, 이전 회기 이어가기, 리뷰 가능 상태를 작업대처럼 배치.</span><code>02-learner-home-responsive.png</code></div>
</article>
<article class="dg-card">
<img src="design-concepts/generated/11-learner-dashboard-redesign-concept-b.png" alt="학습자 대시보드 회기 리캡 중심 재설계 시안" loading="lazy">
<div class="dg-card-body"><b>학습자 대시보드 재설계</b><span>회기 리캡, AI 코치, 이어하기 CTA를 첫 화면의 단일 결정 영역으로 묶음.</span><code>11-learner-dashboard-redesign-concept-b.png</code></div>
</article>
<article class="dg-card">
<img src="design-concepts/generated/12-learner-dashboard-redesign-concept-b-alt.png" alt="학습자 대시보드 대안 재설계 시안" loading="lazy">
<div class="dg-card-body"><b>학습자 대시보드 대안</b><span>마지막 내담자 반응과 내 마지막 반응을 더 강하게 드러낸 대안 레이아웃.</span><code>12-learner-dashboard-redesign-concept-b-alt.png</code></div>
</article>
<article class="dg-card">
<img src="design-concepts/generated/03-session-responsive.png" alt="라이브 상담 세션 PC 태블릿 모바일 반응형 디자인 시안" loading="lazy">
<div class="dg-card-body"><b>라이브 상담 세션</b><span>내담자 무대, 자막, 입력, 라이브 신호, 안전 상태를 몰입형 훈련 화면으로 구성.</span><code>03-session-responsive.png</code></div>
@ -500,7 +526,7 @@
</article>
<article class="dg-card">
<img src="design-concepts/generated/06-admin-responsive.png" alt="관리자 콘솔 PC 태블릿 모바일 반응형 디자인 시안" loading="lazy">
<div class="dg-card-body"><b>관리자 콘솔</b><span>서비스 상태와 사용자 권한 관리를 운영자가 바로 판단할 수 있는 밀도로 정리.</span><code>06-admin-responsive.png</code></div>
<div class="dg-card-body"><b>관리자 콘솔</b><span>운영 홈은 서비스 상태 관측에 집중하고 사용자·권한·티켓은 별도 메뉴로 분리. 티켓과 헬스 샘플은 DB 기반 1차 모델을 사용.</span><code>06-admin-responsive.png</code></div>
</article>
<article class="dg-card">
<img src="design-concepts/generated/07-settings-responsive.png" alt="설정 화면 PC 태블릿 모바일 반응형 디자인 시안" loading="lazy">
@ -554,15 +580,23 @@
</article>
<article class="scard" data-status="done" data-cat="교수자·평가" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">CRITICAL UX 1차 DONE</span><span class="scard-mid"><span class="scard-title">교수자 '학습자 이력·성장 추적' UI/UX<span class="critbadge">핵심 가치</span></span><span class="scard-sum">교수 콘솔에 학습자별 회기 이력, 적절성 추이, 라포, 기법 태그, 최근 회기 포인트를 추가.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p><code>TeacherDashboardResponse.learner_growth</code>를 추가해 턴별 fast-loop 평가의 <code>appropriateness</code>, <code>rapport_signal</code>, 기법 태그를 학습자별로 집계한다. 교수 콘솔은 학습자별 성장 카드, 회기별 막대 추이, 평균 라포, 변화폭, 최근 회기 점검 포인트를 표시한다. 평가가 없는 회기는 "평가 부족"으로 남겨 가짜 곡선을 만들지 않는다.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q</code> 21 passed, <code>pytest app/ -q</code> 119 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code> passed.</p></div><div class="kv k-warn"><b>잔여</b><p>자기효능감·기술숙련도·수련만족도 사전사후(H1)와 교수자 코멘트/검수 워크플로 확장은 별도 평가설계·임상팀 문항 확정이 필요하다.</p></div></div>
<div class="scard-body"><div class="kv"><b>현재</b><p><code>TeacherDashboardResponse.learner_growth</code>를 추가해 턴별 fast-loop 평가의 <code>appropriateness</code>, <code>rapport_signal</code>, 기법 태그를 학습자별로 집계한다. 교수 콘솔은 학습자별 성장 카드, 회기별 막대 추이, 평균 라포, 변화폭, 최근 회기 점검 포인트를 표시한다. 평가가 없는 회기는 "평가 부족"으로 남겨 가짜 곡선을 만들지 않는다. 종료 회기 리뷰 대기 행은 <code>/teach/session/{id}/review</code>로 진입해 같은 회기 리뷰를 교수자 읽기 전용으로 열고, 워크시트는 학습자 제출물 수정 없이 검토 전용으로 표시한다.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q</code> 21 passed, <code>pytest app/test_rbac_idor.py app/test_teacher_dashboard.py -q</code> 9 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed. <code>npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1</code> 4 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>자기효능감·기술숙련도·수련만족도 사전사후(H1)와 교수자 코멘트/검수 워크플로 확장은 별도 평가설계·임상팀 문항 확정이 필요하다.</p></div></div>
</article>
<article class="scard" data-status="done" data-cat="교수자·평가" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">검토 상태 1차 DONE</span><span class="scard-mid"><span class="scard-title">교수자 회기 검토 상태 저장</span><span class="scard-sum"><code>app.session_review_status</code><code>PUT /teacher/sessions/{session_id}/review-status</code>로 검토 메모·완료 상태를 저장.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>교수자/관리자는 <code>/sessions/{id}/review</code>를 읽을 수 있고, 학습자 워크시트 저장은 learner 전용으로 유지한다. 교수자 검토 상태는 <code>app.session_review_status</code><code>review_status</code>, <code>review_note</code>, <code>reviewed_at</code>로 저장되며, teacher dashboard 응답과 <code>SessionReview</code>가 이 값을 표시한다. <code>PUT /teacher/sessions/{session_id}/review-status</code>는 교수자 메모 저장과 검토 완료 처리를 담당하고, 검토 완료된 회기는 pending queue에서 제외한다.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_teacher_dashboard.py app/test_rbac_idor.py app/test_learner_dashboard.py -q</code> 12 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>, <code>teacher.spec.ts</code> 4 passed. 교수 콘솔 검토 큐 노출과 학습자 리뷰의 교수자 전용 빈 grid 제거는 E2E/시각 게이트로 재검증했다.</p></div><div class="kv k-warn"><b>잔여</b><p>임상 루브릭·최종 검수 기준은 외부 정의 전까지 완료 처리하지 않는다.</p></div></div>
</article>
<article class="scard" data-status="done" data-cat="학습자·UX" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">개인화 1차 DONE</span><span class="scard-mid"><span class="scard-title">학습자 홈 대시보드 / 최근 회기 리캡</span><span class="scard-sum"><code>GET /sessions/dashboard</code><code>LearnerHome</code>가 실제 세션 기반 성장·피드백·페르소나 진행을 표시.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>교수자 성장 집계와 중복되던 점수·라포·기법 계산을 <code>app.services.session_metrics</code>로 공용화했다. <code>GET /sessions/dashboard</code>는 학습자 본인 세션만 집계해 <code>overview</code>, <code>growth</code>, <code>persona_progress</code>, <code>achievements</code>, <code>recent_feedback</code>를 반환하고, <code>LearnerHome</code>은 누적 회기·리뷰 대기·최근 평가·라포 흐름·페르소나별 진행·최근 피드백·마지막 세션 리캡을 실제 서버 데이터로 보여준다. 성취는 공식 수료/등급이 아니라 실제 연습 milestone만 사용한다.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_learner_dashboard.py app/test_teacher_dashboard.py app/test_rbac_idor.py -q</code> 12 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>, <code>learner.spec.ts</code> 6 passed, <code>session-review.spec.ts</code> 2 passed, <code>layout-visual-gate.spec.ts</code> 7 passed, <code>session-layout.spec.ts</code> 4 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>UX 감사 기준으로 2차에서는 카드 격자 비중을 줄이고 게이지·small multiples·테이블 중심으로 정리해야 한다. 모바일에서 임상 요약을 단순 숨김 처리하는 부분도 접힘/요약으로 바꾸는 게 맞다.</p></div></div>
</article>
<article class="scard" data-status="planned" data-cat="거버넌스·아키텍처" data-owner="1">
<button class="scard-head" aria-expanded="false"><span class="chip c-plan">중기</span><span class="scard-mid"><span class="scard-title">2-tier 모델 전략 + 백엔드 언어(원안 차이)<span class="ownbadge">내 차례</span></span><span class="scard-sum">2-tier는 ENGINE_MODE로 점진 도입 가능. 백엔드 언어는 소유자 결정.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>① PDF: 고성능(Claude·GPT=내담자발화·실시간평가)/저비용 한국어(Solar=단순응답·사후요약) 2단계 + 캐싱 90%절감. 현재 client AI는 claude_cli 기본 라우팅, fast/deep evaluator는 <code>EVALUATOR_FAST_MODEL</code>/<code>EVALUATOR_DEEP_MODEL</code> 설정 시 모델 override를 gateway에 전달한다. 벤더중립 인터페이스와 L0~L2 cache hint/session reuse는 있음. ② PDF는 <b>Node.js 우선</b>(팀 보유기술·5개월 일정)/Spring 대안. 현재 <b>Python/FastAPI</b> — 재작성은 일정·인력 리스크.</p></div><div class="kv k-warn"><b>권고</b><p>2-tier는 evaluator 모델 override 이후 client AI/요약 모델 정책과 semantic cache로 점진 도입. 백엔드 언어는 <b>소유자 결정</b>(팀 역량·유지보수 vs 재작성 비용).</p></div></div>
</article>
<article class="scard" data-status="done" data-cat="코어·차별" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">검증 ✓</span><span class="scard-mid"><span class="scard-title">무회귀 검증 종합</span><span class="scard-sum">백엔드 122 + E2E 42 + 시각게이트 7/7 통과. 스택 라이브.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>페르소나 격리(캐릭터 붕괴 버그)·RAG 비차단(세션생성 블로킹 회귀)·역린/misconduct·init_state 파라미터객체·데드코드(LogHook·tier·RMS)·OAuth state 복구·X1 dataset export dry-run — <b>백엔드 122 + E2E 42(데스크27·모바일11·아바타4) + 시각게이트 7/7</b> 통과. 스택 라이브(web·api·gateway·voice·RAG).</p></div><div class="kv"><b>다음</b><p>남은 원천문서 갭과 임상 콘텐츠 적재를 순서대로 닫는다.</p></div></div>
<button class="scard-head" aria-expanded="false"><span class="chip c-done">검증 ✓</span><span class="scard-mid"><span class="scard-title">무회귀 검증 종합</span><span class="scard-sum">이번 전체 검증: Playwright E2E 113 + build/typecheck 통과.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>이번 점검 범위인 SEO/GEO 공유 카드, 온보딩 반영 E2E, 학습자 홈, 회기 시작/종료, 리뷰 공유 버튼, 음성 성공 경로, 레이아웃 회귀는 <b><code>npm run e2e</code> 113 passed + <code>npm run typecheck</code>/<code>npm run build</code></b>로 닫았다. 공개 API와 Tailnet 스모크도 녹색이고, 패키징 점검에서 더미 운영 env 기준 Docker compose fresh smoke도 통과했다. 과거 focused suite와 Phase 3 evidence는 별도 행의 과거 증거로 유지하되, 이번 완료 증거와 섞어 과장하지 않는다.</p></div><div class="kv"><b>다음</b><p>남은 원천문서 갭, 임상 루브릭/최종 평가설계, public Google OAuth 실제 <code>/turn</code>, 배포지 운영 비밀값 주입을 순서대로 닫는다.</p></div></div>
</article>
</div>
</div>
@ -587,17 +621,17 @@
<button class="scard-head" aria-expanded="false"><span class="chip c-plan">HIGH H1</span><span class="scard-mid"><span class="scard-title">계약 평가 KPI(자기효능감·기술숙련도·수련만족도 사전사후) 수집·집계 전무<span class="ownbadge">내 차례</span></span><span class="scard-sum">doc4가 3척도 pre-post를 명시했으나 코드 0건. 평가설계는 소유자 결정.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>doc4가 20명 실험/통제군·단회기 50분·3척도 pre-post를 못박았으나 코드 0건. Phase3 KPI도 report shape만, 계산 코드 0줄. (κ/ICC·환각률은 doc4 미명시 — 평가설계 문서 확정 필요.)</p></div><div class="kv k-warn"><b>권고</b><p>3척도 pre-post 폼·실험/통제군 배정·자동누적 대시보드·추이 시각화·검정 계산 코드. 평가설계는 소유자 결정.</p></div></div>
</article>
<article class="scard" data-status="doing" data-cat="교수자·평가" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">H2 2차 DONE</span><span class="scard-mid"><span class="scard-title">턴별 회기 리뷰(상호작용 분석) — 평가 SSOP 적재 확대</span><span class="scard-sum">submit/stream/voice 경로의 fast-loop 평가를 DB 정규화 테이블에 적재·복원.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>텍스트 <code>/turn</code>·<code>/stream</code>·음성 경로에서 fast-loop 턴 평가를 <code>TurnRecord.evaluation</code>에만 남기지 않고 <code>app.feedback_scores</code>, <code>app.turn_technique</code>, <code>app.turn_client_state</code>, <code>app.supervisor_comment</code>, <code>app.alternative_utterance</code>로 정규화 적재 후 리뷰 조회에서 hydrate한다. 원시 평가 테이블은 evaluator/admin RLS로 제한하고 서버가 evaluator 컨텍스트로 적재한다. <code>app.turns</code> insert도 <code>RETURNING id</code> 확인으로 0-row silent success를 차단. 검증: <code>pytest app/test_evaluation_persistence.py -q</code> 6 passed, <code>pytest app/test_session_turn_persistence.py app/test_evaluation_persistence.py -q</code> 21 passed, <code>pytest app/</code> 119 passed + <code>pytest engine_gateway/</code> 7 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>더 큰 <code>finalize_turn</code>/<code>SessionRepository</code> 경계 · few-shot 골든셋 기본 OFF · 회기리뷰 2열 UI(축어록 타임라인+비언어) 프론트 강화 · 원천 축어록 골든셋 적재(임상팀).</p></div></div>
<article class="scard" data-status="done" data-cat="교수자·평가" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">H2 LIVE DONE</span><span class="scard-mid"><span class="scard-title">턴별 fast-loop + 라이브 코칭·이력</span><span class="scard-sum">평가 정규화에 더해 워크북·DSM·공식 지침/RAG 기반 코칭 아바타, 근거 모달, 발화별 이력 오버레이와 source pack RAG sync를 연결.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>텍스트 <code>/turn</code>·<code>/stream</code>·음성 경로에서 fast-loop 턴 평가를 <code>app.feedback_scores</code>, <code>app.turn_technique</code>, <code>app.turn_client_state</code>, <code>app.supervisor_comment</code>, <code>app.alternative_utterance</code>로 정규화 적재 후 리뷰 조회에서 hydrate한다. 이번 패스에서 <code>app/services/live_coach.py</code>, <code>data/kb/live_coaching_workbook_0615.json</code>, <code>data/kb/live_coaching_sources/*.json</code>, <code>POST /sessions/{id}/live-coach</code>, <code>GET /sessions/{id}/live-coach</code>, <code>POST /kb/live-coach/source-packs/sync</code>, <code>app.live_coach_events</code>를 추가했다. 세션 UI는 코칭 모드에서 AI 코치 아바타 말풍선으로 즉시 개입하고, "근거 보기" 모달과 학습자 발화 우측 <code>C</code> 마커를 통해 받은 코칭 이력을 채팅 위 스크롤 오버레이로 보여준다. 저장 payload는 PII 마스킹 excerpt + 코칭 구조화 JSON이며, 공식 자료는 허가된 source pack의 version/citation/summary로 넣고 장문 원문·공식 문항을 재현하지 않는다. 같은 source pack은 관리자 sync로 evaluator 전용 RAG에 증분 색인한다.</p></div><div class="kv"><b>검증</b><p><code>python -m compileall app -q</code>, <code>pytest app/test_live_coach_sources.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q</code> 27 passed, <code>npm run generate:api-types</code>, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>, <code>npm run e2e -- e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts</code> 15 passed. 로컬 DB smoke: source pack sync 4 sources/15 chunks embedded, 재호출 <code>skipped_unchanged=4</code>, <code>official_suicide_risk_guidelines</code> source filter 검색 성공.</p></div><div class="kv k-warn"><b>잔여</b><p>임상팀 골든셋·라이브 코칭 루브릭 검수, 회기리뷰 2열 UI 고도화, 공식 지침/논문 source pack 추가와 버전 운영정책 보강이 남아 있다.</p></div></div>
</article>
<article class="scard" data-status="doing" data-cat="콘텐츠·산출물" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">HIGH H3 2차 DONE</span><span class="scard-mid"><span class="scard-title">임상팀 콘텐츠 입력 경로(페르소나 저작 CRUD) + P4~P7 적재</span><span class="scard-sum">draft CRUD와 교수 콘솔 JSON 초안 패널 연결. 저장소 P4~P7 JSON도 승인 seed 경로에 연결.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p><code>POST/GET/PUT /personas/drafts</code>를 추가해 teacher/admin이 페르소나 카드를 draft로 생성·조회·편집하고 review 상태로 올릴 수 있다. 교수 콘솔에는 JSON 초안 패널과 검수 큐의 편집 불러오기를 연결했다. 승인된 카드만 학습자 catalog에 노출되는 기존 경계는 유지. 이번 2차에서는 <code>data/personas/P4.json</code>~<code>P7.json</code><code>PersonaCard</code>로 읽어 <code>materialize_seed_personas()</code>와 seed fallback catalog에 포함했다.</p></div><div class="kv"><b>검증</b><p><code>python -m pytest app/test_persona_review.py -q</code> 22 passed. 로컬 dev DB <code>materialize_seed_personas()</code> 결과 <code>approved_codes=P1,P2,P3,P4,P5,P6,P7</code>, 인증된 로컬 <code>GET /personas</code><code>P1..P7</code> 반환.</p></div><div class="kv k-warn"><b>잔여</b><p>JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수/서면 evidence는 후속. 임상팀이 제공하지 않은 페르소나 내용을 임의 생성하지 않는다.</p></div></div>
<button class="scard-head" aria-expanded="false"><span class="chip c-doing">HIGH H3 4차 DONE</span><span class="scard-mid"><span class="scard-title">페르소나 스튜디오 + RAG 첨부 SSOT 생성</span><span class="scard-sum">JSON 패널을 별도 저작 스튜디오로 분리하고, 실제 기록·교재 자료를 KB 근거 문서로 등록한 뒤 초안을 생성한다.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>teacher/admin 전용 <code>/teach/personas</code>를 3열 스튜디오로 추가했다. 좌측은 저작 흐름·검수 큐·공개 카탈로그, 중앙은 RAG 자료 등록과 개요/임상/저항/회기/말투/안전/프롬프트 항목형 편집 탭, 우측은 검증·생성 근거·승인/반려 결정이다. 교수 콘솔은 진입점과 triage만 맡고 저작 본체는 스튜디오가 맡는다. 2026-06-27 UI 정리에서 중복 네이티브 파일 입력을 커스텀 SSOT 첨부 드롭존으로 교체하고, 저작 스테퍼·탭별 작성 가이드·검증 dot 정렬을 보강했다. 레이아웃 근거와 생성 시안은 <code>docs/ops/layout-research-2026-06-27/persona-dashboard-layout-guideline.md</code>에 묶었다. <code>POST /personas/sources</code>는 첨부/붙여넣기 자료를 PII 마스킹 후 <code>kb.source/document/chunk</code>에 evaluator 전용(<code>visible_to=['evaluator']</code>, <code>sensitivity=2</code>) 근거 문서로 등록한다. <code>POST /personas/drafts/generate</code><code>source_id</code> 기반 RAG 검색 결과만 엔진에 넘기고, draft <code>source_provenance</code>와 응답에 source id·doc/content hash·chunk id를 남긴다. 승인된 카드만 학습자 catalog에 노출되는 경계는 유지하되, P1~P7 시스템 페르소나는 DB 저작 카탈로그의 초기 부트스트랩으로 승격했다. 교수자는 공개 목록의 <code>수정</code>으로 승인본을 같은 <code>persona_id</code>의 다음 버전 draft로 복제해 편집하고, <code>삭제</code>는 기존 회기 FK를 지키기 위해 같은 code family를 <code>archived</code>로 보관 처리한다. 기동 materializer는 누락분만 insert하고 기존 편집/archived row를 덮어쓰거나 되살리지 않는다.</p></div><div class="kv"><b>검증</b><p><code>python -m pytest app/test_persona_review.py -q</code> 31 passed, <code>npm run generate:api-types</code>, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed. PersonaStudio 공개본 수정/삭제 E2E: <code>npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1</code> 6 passed, <code>layout-visual-gate.spec.ts</code> chromium-single-run 8 passed, <code>session-layout.spec.ts</code> desktop/mobile 8 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>원문 격리본(<code>sensitivity=3</code>) 별도 보관, source/version 전용 마이그레이션 runner, prompt-bundle version pinning, 임상팀 최종 검수/서면 evidence, 루브릭·이론 콘텐츠 외부화는 후속이다. 임상팀이 제공하지 않은 페르소나 내용을 최종 승인본처럼 확정하지 않는다.</p></div></div>
</article>
<article class="scard" data-status="done" data-cat="보안·PII" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">H4 부분 DONE</span><span class="scard-mid"><span class="scard-title">PII 마스킹 + LLM 호출 감사 + 동의 게이트</span><span class="scard-sum">metadata-only llm_call_log, learner consent_at 하드게이트. 전체 API 125.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 <code>/turn</code> smoke에서 <code>audit.llm_call_log</code>가 8→11로 3행 증가했다. 이번 패스에서 <code>app_user.consent_at</code> 기반 learner 전용 동의 수락/철회 API와 회기 시작·voice dev persona 시작 하드게이트를 추가했고, 세션 시작 전 프론트 동의 UI와 E2E seed를 연결했다. 검증: 동의/세션/음성 회귀 64 passed, 전체 API 최신 130 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>.</p></div><div class="kv k-warn"><b>잔여</b><p>이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 서명 동의서·개인정보 고지 evidence는 외부 게이트다. 공개 Google OAuth 실제 <code>/turn</code> proof는 별도 운영 게이트.</p></div></div>
<button class="scard-head" aria-expanded="false"><span class="chip c-done">H4 부분 DONE</span><span class="scard-mid"><span class="scard-title">PII 마스킹 + LLM 호출 감사 + 프로필 온보딩·동의 게이트</span><span class="scard-sum">metadata-only llm_call_log, 단순 가입정보 온보딩, 약관 버전, 전역 URL 하드게이트. 전체 E2E 113.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>정규식 폴백에 한국어 고위험 패턴 추가: 구체 날짜(2001.4.18→[DATE])·금액(1200원→[MONEY])·행정구역 주소(서울시 강남구 역삼동→[ADDR]). 상담 생성(generate/stream)·fast/deep 평가의 외부 LLM 호출 직후 <code>audit.llm_call_log</code>에 provider/model/token/cost/inference_geo/latency만 적재한다. prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 <code>/turn</code> smoke에서 <code>audit.llm_call_log</code>가 8→11로 3행 증가했다. 이번 패스에서 <code>app_user</code>에 이름·소속·학과·학년/직위·연락처·주소/수령지·닉네임·자기소개·아바타 URL·약관/개인정보 동의 시각·버전을 추가했다. 로그인 후 <code>onboarding_completed_at</code>이 없으면 전역 <code>OnboardingGate</code><code>/onboarding</code> 외 URL(<code>/learn</code>, <code>/settings</code>, <code>/admin</code>, <code>/dev/avatar-preview</code>, <code>/login</code> 포함)을 모두 온보딩으로 돌리고, 온보딩 중에는 공용 셸 메뉴 없이 가입 직후 사용자 정보 입력 폼만 보여준다. learner 회기 시작도 온보딩 완료 전에는 막는다. <code>/users/me/avatar</code>는 PNG/JPG/WebP MIME·시그니처·3MB 제한 후 파일을 <code>USER_UPLOAD_DIR/profile-avatars</code>에 저장하고 URL만 보관한다. 약관·개인정보 처리방침 초안은 <code>/users/legal-docs</code>에서 제공한다. 관리자/교수자 권한은 온보딩에서 받지 않고 allowlist/관리자 사용자 관리로만 부여한다. 검증: <code>pytest app/ -q</code> 145 passed, <code>pytest engine_gateway/ -q</code> 9 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>, auth E2E 7 passed, 최종 <code>PLAYWRIGHT_PORT=5174 npm run e2e</code> 113 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>이름/기관명은 NER 필요(Presidio ko 모델·spaCy ko, 무거움 — TODO 명시). 미성년/guardian 및 법무 검토가 필요한 최종 서명 동의서·개인정보 처리방침·약관 evidence는 외부 게이트다. 공개 Google OAuth 실제 <code>/turn</code> proof는 별도 운영 게이트.</p></div></div>
</article>
<article class="scard" data-status="planned" data-cat="리팩터·정리" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-plan">MED M1~M3</span><span class="scard-mid"><span class="scard-title">비언어 임상이벤트(M1)·다회기 케이스 아크(M2)·SSO claim/식별자/감사(M3)</span><span class="scard-sum">M1/M2 1차 완료. M3는 cohort map + provider external_id 1차 완료, 기관 SAML/거버넌스는 남음.</span></span><span class="caret" aria-hidden="true"></span></button>
@ -621,7 +655,7 @@
<div class="scards">
<article class="scard" data-status="done" data-cat="인프라·스택·RAG" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">DEV STACK</span><span class="scard-mid"><span class="scard-title">로컬 개발 스택 1-커맨드 기동 — <code>scripts/dev-up.ps1</code></span><span class="scard-sum">게이트웨이9099+API8000+웹5173+Docker DB 보장. 3포트 health OK + 실 AI 턴.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p>게이트웨이(9099 claude_cli)+API(8000, DB 미가용 시 in-memory degraded)+웹(5173)을 커맨드라인 기준 정리 후 결정론적 기동(<code>--reload</code> 워처 불안정 회피, uvicorn 있는 python 자동 해석). Docker가 사용 가능하면 <code>vignette-dev-db</code> healthcheck/pg_isready/role safety를 점검하고, compose <code>infra\.env</code> 필수값 누락도 경고한다. <code>-NoGateway</code>/<code>-NoWeb</code>는 해당 컴포넌트 정리도 건너뛰고, API 정리는 지정 <code>-ApiPort</code>만 대상으로 한다. dev-login + seed 페르소나. 로그 <code>.devlogs/</code>(gitignore). <b>검증</b>: 3포트 health OK + 실제 AI 턴 생성·회기리뷰 동작, API-only 재기동 후 8001/8010/5173/9099/20241 보존.</p></div><div class="kv k-warn"><b>비고</b><p>AI 턴은 로컬 claude CLI 사용(사용량 발생). 진입점 http://localhost:5173. <code>dev-down.ps1</code>은 기본적으로 DB 컨테이너를 보존하고, <code>-Db</code> 지정 시 함께 중지한다. 기존 <code>vignette-dev-db</code>는 accepting connections이나 healthcheck가 없고, <code>vignette_app</code>은 NOBYPASSRLS로 존재하지만 현재 API startup DDL은 owner 권한이 필요해 런타임 app-role 분리는 후속이다. <b>주의(무DB 한정)</b>: DB 없이 degraded면 페르소나가 <code>source:seed_fallback·degraded:true</code>라 프론트 <code>isUsablePersona</code>가 막아 "회기 시작" 버튼이 비활성 → UI 세션 시작엔 실제 Postgres(<code>127.0.0.1:55432</code>)가 필요.</p></div></div>
<div class="scard-body"><div class="kv"><b>현재</b><p>게이트웨이(9099 claude_cli)+API(8000, DB 미가용 시 in-memory degraded)+웹(5173)을 커맨드라인 기준 정리 후 결정론적 기동(<code>--reload</code> 워처 불안정 회피, uvicorn 있는 python 자동 해석). Docker가 사용 가능하면 <code>vignette-dev-db</code> healthcheck/pg_isready/role safety를 점검하고, compose <code>infra\.env</code> 필수값 누락도 경고한다. <code>-NoGateway</code>/<code>-NoWeb</code>는 해당 컴포넌트 정리도 건너뛰고, API 정리는 지정 <code>-ApiPort</code>만 대상으로 한다. dev-login + seed 페르소나. 로그 <code>.devlogs/</code>(gitignore). <b>검증</b>: 3포트 health OK + 실제 AI 턴 생성·회기리뷰 동작, API-only 재기동 후 8001/8010/5173/9099/20241 보존.</p></div><div class="kv k-warn"><b>비고</b><p>AI 턴은 로컬 claude CLI 사용(사용량 발생). 진입점 http://localhost:5173. <code>dev-down.ps1</code>은 기본적으로 DB 컨테이너를 보존하고, <code>-Db</code> 지정 시 함께 중지한다. 기존 <code>vignette-dev-db</code>는 accepting connections이나 healthcheck가 없고, <code>vignette_app</code>은 NOBYPASSRLS로 존재한다. 이번 패키징 smoke에서 fresh compose DB는 owner/app role 분리 상태로 API healthy까지 통과했고, 기존 dev DB의 운영 role 전환은 별도 owner migration 확인 후 진행한다. <b>주의(무DB 한정)</b>: DB 없이 degraded면 페르소나가 <code>source:seed_fallback·degraded:true</code>라 프론트 <code>isUsablePersona</code>가 막아 "회기 시작" 버튼이 비활성 → UI 세션 시작엔 실제 Postgres(<code>127.0.0.1:55432</code>)가 필요.</p></div></div>
</article>
<article class="scard" data-status="done" data-cat="인프라·스택·RAG" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">VOICE 작동</span><span class="scard-mid"><span class="scard-title">음성 채팅 "바로 꺼짐" 해결 — provider 키 부재였음</span><span class="scard-sum">키 없으면 1011 즉시 close. .env에 OPENAI_API_KEY 설정→정상. TTS 지문 제거·아바타 모션 수정.</span></span><span class="caret" aria-hidden="true"></span></button>
@ -639,6 +673,10 @@
<button class="scard-head" aria-expanded="false"><span class="chip c-done">REFACTOR 2 4차 DONE</span><span class="scard-mid"><span class="scard-title">백엔드 Pydantic ↔ 프론트 <code>lib/api.ts</code> 계약 SSOT화</span><span class="scard-sum">OpenAPI→<code>api.gen.ts</code> 생성 + CI stale 체크 게이트. 세션 계열 수기 DTO도 생성 타입 alias로 축소.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p><code>apps/api/scripts/export-openapi.py</code><code>apps/web/scripts/generate-api-types.mjs</code>로 FastAPI OpenAPI를 <code>apps/web/src/lib/api.gen.ts</code>에 생성한다. <code>npm run check:api-types</code>가 재생성 누락을 잡는다. <code>MeResponse</code>, <code>AuthConfigResponse</code>, <code>ConsentResponse</code>, <code>PersonaSummary</code>, <code>PersonaReviewSummary</code>, admin/user/engine DTO, review leaf DTO, <code>CrisisResourceResponse</code>, teacher safety/growth leaf DTO, <code>LearnerSessionsResponse</code>, <code>SessionReviewResponse</code>, worksheet aggregate, <code>TeacherDashboardResponse</code>, <code>TeacherSessionSummary</code>, <code>TeacherLearnerGrowth</code>에 이어 <code>SessionStartResponse</code>, <code>SessionDetailResponse</code>, <code>SessionDetailTurn</code><code>ApiSchema&lt;...&gt;</code> alias로 전환했다. 세션 응답 stage는 backend <code>StageLabel</code> enum으로 고정해 OpenAPI가 <code>라포|탐색|개입|정리</code> union을 생성한다. generated optional/default 차이는 화면 form state, notification default helper, 배열 렌더링 fallback으로 흡수한다. <code>.github/workflows/api-contract.yml</code>은 PR/master push에서 같은 stale check를 실행해 OpenAPI type drift를 CI에서 막는다.</p></div><div class="kv"><b>검증</b><p><code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed. <code>python -m pytest app/test_session_turn_persistence.py -q</code> 16 passed. 영향 범위 E2E: <code>learner.spec.ts</code> + <code>session-review.spec.ts</code> + <code>teacher.spec.ts</code> + <code>layout-visual-gate.spec.ts</code> 32 passed.</p></div><div class="kv k-warn"><b>잔여</b><p>평가 조회·리뷰 도식·교수 콘솔 등 아직 자유 문자열로 남긴 stage-like 필드는 각 도메인 의미를 확인한 뒤 별도 축소한다.</p></div></div>
</article>
<article class="scard" data-status="done" data-cat="인프라·스택·RAG" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">SEO/GEO 공유</span><span class="scard-mid"><span class="scard-title">검색 메타 + 회기 리뷰 URL 공유 카드</span><span class="scard-sum">robots/sitemap/llms + OG/JSON-LD. 종료 회기는 토큰 공유 URL로 교수자에게 요약·썸네일 전달.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p><code>apps/web/index.html</code>에 canonical, description, Open Graph/Twitter Card, JSON-LD(WebApplication)를 추가했고, <code>public/robots.txt</code>, <code>sitemap.xml</code>, <code>llms.txt</code>로 공개 사이트와 AI 검색용 설명을 정리했다. 인증 내부 경로는 robots에서 차단한다. 학습자 회기 리뷰는 종료 후 <code>POST /sessions/{id}/share</code>로 공개 토큰을 만들고, <code>GET /share/session/{token}</code>이 서버 렌더 HTML/OG/JSON-LD를 반환한다. <code>app.session_share_link</code>에는 토큰 해시와 sanitized preview payload만 저장하며 원문 축어록·학습자 식별자는 저장하지 않는다.</p></div><div class="kv"><b>검증</b><p><code>pytest app/test_session_share.py app/test_session_turn_persistence.py -q</code> 21 passed, <code>python -m pytest app/ -q</code> 145 passed, <code>python -m pytest engine_gateway/ -q</code> 9 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code> passed.</p></div><div class="kv k-warn"><b>판정</b><p>공개 제품 페이지는 색인 가능, 회기 공유 페이지는 교수자 전달용 unfurl 카드라 <code>noindex</code>와 API robots <code>Disallow: /share/</code>를 유지한다. 실제 공개 도메인 unfurl 검증은 배포 후 1회 필요하다.</p></div></div>
</article>
<article class="scard" data-status="done" data-cat="리팩터·정리" data-owner="0">
<button class="scard-head" aria-expanded="false"><span class="chip c-done">REFACTOR 3 일부</span><span class="scard-mid"><span class="scard-title">죽은 확장점/계약 정리 — LogHook·tier 제거 적용</span><span class="scard-sum">caller 0 타입·분기·export 제거. 잔여는 패치 문서에 스테이징. 전체 API 119.</span></span><span class="caret" aria-hidden="true"></span></button>
<div class="scard-body"><div class="kv"><b>현재</b><p><code>LogHook</code>/<code>log_hook</code>(caller 0) 타입·파라미터·분기·export 제거 · 게이트웨이/엔진클라 <code>tier</code> dead-contract 제거(write-only, gateway는 model만 사용). 잔여(RMS 힌트·Live2D 고아 자산·파라미터객체 init_state/prepare_turn/upsert)는 <code>docs/ops/refactor-rag-patches-2026-06-26.{md,json}</code>에 스테이징(단계 적용).</p></div><div class="kv"><b>참고</b><p>일괄 적용 대신 라이브 스택 보호 위해 그룹별 적용+pytest 검증.</p></div></div>
@ -696,7 +734,7 @@
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>프로세스 난립 정리</b><p>운영 확인용 프로세스와 로컬/Tailnet 검증용 dev 프로세스를 의도적으로 분리해 유지한다.</p></div><div><b>산출물</b><p>9099 engine, 8001 prod API, 8000/8010 dev API, 5173 web, cloudflared tunnel 1개</p></div><div><b>검증</b><p><code>Get-NetTCPConnection</code>에서 대상 포트별 listener 확인</p></div></div>
<div class="task-row"><div><span class="task-status s-plan">GATE</span></div><div><b>한신대 공문/데이터 거버넌스 게이트 정리</b><p>SSO 클레임, 추가 축어록 수급, 미성년 원본 활용동의, 개인정보 처리방침을 P1 진입 전 외부 의존성으로 명확히 둔다. 로컬 문서 골격은 준비됐지만 한신대/데이터 steward의 written evidence는 아직 필요하다.</p></div><div><b>산출물</b><p><code>docs/ops/hanshin-data-governance-gate.md</code>, 공문 질의 항목, 동의 범위 체크리스트, SSO claim mapping 표</p></div><div><b>검증</b><p>문서 artifact 작성 완료; IRB 게이트가 아니라 데이터/SSO 게이트로 외부 증거 필요 상태 유지</p></div></div>
</div>
<div class="source-note"><b>팀장 판정:</b> 로컬 회귀와 공개 prod-safe 게이트는 통과했다. Cloudflare Pages production <code>12da49f3</code>이 commit <code>0ec266a</code> dist로 배포됐고, 운영 반영 전 남은 핵심 증거는 공개 Google OAuth 실제 <code>/turn</code> smoke다. 런타임 mock/demo 자산은 운영 번들에서 제거하고 테스트 fixture만 남긴다.</div>
<div class="source-note"><b>팀장 판정:</b> 로컬 회귀와 공개 prod-safe 게이트는 통과했다. Cloudflare Pages production <code>24c90784</code>가 현재 dirty local source dist로 배포됐고, 운영 반영 전 남은 핵심 증거는 공개 Google OAuth 실제 <code>/turn</code> smoke다. 런타임 mock/demo 자산은 운영 번들에서 제거하고 테스트 fixture만 남긴다.</div>
<div class="tab-subhead"><h4>Phase 0 완료 상세 목록</h4><p>이전 "완료" 카드 내용을 탭 안에서 항목별로 보존한다.</p></div>
<div class="task-list">
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>DB persona 기반 테스트</b><p><code>session-persistence</code>, <code>teacher</code>, <code>voice</code><code>/api/personas</code>에서 실제 DB persona를 가져온다.</p></div><div><b>산출물</b><p>seed persona 직접 결합 제거, 실제 persona catalog 기반 E2E</p></div><div><b>검증</b><p>persona 관련 E2E가 DB-backed persona로 통과</p></div></div>
@ -735,7 +773,7 @@
<div class="detail-head"><div><h3>Phase 2a 권한, OAuth, 페르소나 상세</h3><p>음성과 섞지 않고 3역할, 권한격리, 페르소나 확장을 독립 트랙으로 닫는다.</p></div><div class="mini-metrics"><span>주차: 9-13</span><span>우선순위: P1</span><span>IDOR 검증 필수</span></div></div>
<div class="task-list">
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>OAuth BFF / HttpOnly 세션 완성</b><p>Auth Code + PKCE/state와 opaque HttpOnly/Secure cookie 계약을 회귀화했다. OAuth state는 HMAC 서명 토큰과 HttpOnly state cookie를 함께 검증해 API 재시작 뒤 콜백이 <code>invalid_state</code>로 떨어지지 않으면서도 브라우저 시작 흐름에 묶인다. Google provider error는 <code>access_denied</code>/<code>provider_error</code>로 분리하고 로그인 화면에 reason code를 표시한다. 공개 Google OAuth 실제 <code>/turn</code> proof는 P0 blocker로 남아 있다.</p></div><div><b>산출물</b><p><code>app/test_auth_providers.py</code>, cookie-bound signed PKCE state, OAuth callback cookie path, login reason UI</p></div><div><b>검증</b><p>auth URL secret 미노출, browser token 미저장, dev/prod cookie 속성 분리, state cookie 불일치 차단, 재시작 후 signed state 콜백 복구, provider error reason 분리</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>학습자/교수자/관리자 권한 격리</b><p>runtime fallback에서 타 learner session 직접 접근 403과 learner list 필터를 회귀화했고, teacher/admin durable read audit insert 경로를 추가했다. live Postgres(NOBYPASSRLS <code>vignette_app</code> 역할)로 RLS/audit smoke를 실행해 운영 증거를 확보했다: learner 세션/턴 격리, teacher cohort 스코프, teacher/admin read_session audit insert가 실제 정책 하에서 PASS.</p></div><div><b>산출물</b><p><code>app/test_rbac_idor.py</code>, <code>audit.audit_log</code> read_session insert, <code>scripts/check-postgres-rls-audit.py</code>(live PASS)</p></div><div><b>검증</b><p>타인 <code>session_id</code> 직접 입력 403(unit), live RLS smoke 5 checks PASS</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>학습자/교수자/관리자 권한 격리</b><p>runtime fallback에서 타 learner session 직접 접근 403과 learner list 필터를 회귀화했고, teacher/admin durable read audit insert 경로를 추가했다. live Postgres(NOBYPASSRLS <code>vignette_app</code> 역할)로 RLS/audit smoke를 실행해 운영 증거를 확보했다: learner 세션/턴 격리, teacher cohort 스코프, teacher/admin read_session audit insert가 실제 정책 하에서 PASS. 이번 패스에서 역할(role)과 관리자 페이지 진입권(<code>admin_access</code>)을 분리했고, <code>AUTH_SUPER_ADMIN_EMAILS</code> 슈퍼 관리자만 관리자 권한을 부여·회수할 수 있게 했다. 슈퍼 관리자는 학습자/교수자 라우트도 통과할 수 있지만, 일반 <code>admin_access</code>는 관리자 콘솔 진입권으로만 쓰인다.</p></div><div><b>산출물</b><p><code>app_user.admin_access</code>, <code>require_admin_access()</code>, <code>/admin/users</code> 권한 위임 UI, <code>app/test_rbac_idor.py</code>, <code>audit.audit_log</code> read_session insert, <code>scripts/check-postgres-rls-audit.py</code>(live PASS)</p></div><div><b>검증</b><p>타인 <code>session_id</code> 직접 입력 403(unit), live RLS smoke 5 checks PASS, 관리자 권한 위임 회귀 <code>app/test_auth_providers.py app/test_admin_ops.py</code> 32 passed</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b><code>visible_to</code>와 인간 권한 레이어 분리</b><p>learner detail/review와 client engine history에서 evaluator-only turn을 제외한다. live Postgres smoke가 evaluator-only DB 정책을 실제로 검사해 PASS: evaluator-only turn이 learner/client/counselor AI에는 숨겨지고 evaluator AI에만 보인다.</p></div><div><b>산출물</b><p><code>TurnRecord.visible_to</code>, learner/client/evaluator filters, <code>docs/ops/postgres-rls-audit-smoke.md</code></p></div><div><b>검증</b><p>evaluator-only turn 숨김 unit proof + live RLS smoke <code>visible_to.evaluator_only_hidden</code> PASS</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>페르소나 P2/P3 교수 검수 루프</b><p>approved catalog와 draft/review 교수 검수 큐를 분리하고, teacher/admin 승인·반려 mutation과 교수 콘솔 UI proof를 추가했다. 교수 콘솔의 404 raw 노출도 제거(404→정상 빈상태)했다.</p></div><div><b>산출물</b><p><code>/personas/review</code>, <code>POST /personas/review/{persona_id}</code>, professor review panel</p></div><div><b>검증</b><p><code>app.test_persona_review</code> 14 tests; teacher UI single-run E2E 1 passed; live RLS smoke PASS</p></div></div>
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>SAML SP 스캐폴드 선반영</b><p>Google OIDC와 별도 provider 상태를 <code>/auth/config</code>에 노출하고, Redirect-binding AuthnRequest와 dev-only unsigned fixture ACS를 backend proof 수준으로 구현했다. 서명 검증이 필요한 운영 SAML ACS는 아직 미구현이며 fingerprint 설정 시 거부한다.</p></div><div><b>산출물</b><p><code>AUTH_SAML_ENABLED</code>, <code>SAML_SP_ENTITY_ID</code>, <code>SAML_SSO_URL</code>, RelayState store, <code>/auth/saml/acs</code> fixture endpoint</p></div><div><b>검증</b><p>AuthnRequest issuer/ACS/destination, RelayState 검증, opaque HttpOnly cookie, bad RelayState/signature-required reject unit test</p></div></div>
@ -746,7 +784,7 @@
<div class="task-list">
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>voice gateway WebSocket</b><p><code>/voice/ws</code> backend contract를 fake WebSocket으로 회귀화했고, Session UI의 MediaRecorder 기반 브라우저 경로도 synthetic audio로 한 턴 통과시켰다. 실제 AudioWorklet 전환, public WSS, 50분 양방향 실측은 남아 있다.</p></div><div><b>산출물</b><p><code>app/test_voice_ws.py</code>, <code>apps/web/e2e/voice-success.spec.ts</code>, audio_start/binary/audio_end/text_turn/ping contract</p></div><div><b>검증</b><p>5 backend tests OK; voice-success chromium-single-run 2 passed; auth guard, chunk concat, max audio cap, TTS chunk envelope</p></div></div>
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>Deepgram STT + EOT</b><p>provider-neutral EOT helper로 final transcript와 silence threshold readiness를 회귀화했다. 실제 Deepgram interim/final 스트림 연결은 남아 있다.</p></div><div><b>산출물</b><p><code>assess_end_of_turn</code>, <code>EndOfTurnDecision</code>, EOT silence threshold</p></div><div><b>검증</b><p>final pending, empty transcript, silence threshold 케이스 unit test</p></div></div>
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>OpenAI TTS voice preset 추상화</b><p>P1/P2/P3 persona voice preset, invalid preset fallback, speed clamp, instructions payload 계약을 회귀화했다. DB <code>voice_persona_map</code>과 live TTS 호출은 남아 있다.</p></div><div><b>산출물</b><p><code>resolve_voice</code>, <code>build_tts_payload</code>, provider-neutral params test</p></div><div><b>검증</b><p>P1/P2/P3 voice 분리, fallback, fake-client streaming payload OK</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>OpenAI TTS voice preset 추상화</b><p>P1/P2/P3 persona voice preset, invalid preset fallback, speed clamp, instructions payload 계약을 회귀화했고, <code>app.persona_voice_map</code>을 live <code>/voice/ws</code> TTS 선택에 연결했다. 명시 preset은 DB map보다 우선하고, OpenAI가 아닌 provider row는 기존 fallback으로 안전하게 흡수한다.</p></div><div><b>산출물</b><p><code>resolve_voice</code>, <code>resolve_voice_from_map</code>, <code>build_tts_payload</code>, <code>get_session_voice_map</code>, seed voice map row, dev runtime schema guard</p></div><div><b>검증</b><p><code>pytest app/test_runtime_policy.py app/test_persona_review.py app/test_voice_service.py app/test_voice_ws.py -q</code> 75 passed; full API 175 passed + gateway 9 passed.</p></div></div>
<div class="task-row"><div><span class="task-status s-plan">POC</span></div><div><b>P1 서연 무참조 synthetic voice 샘플</b><p>Higgs v3 로컬 런타임에서 실존 reference voice 없이 synthetic seed를 먼저 만들고, 그 seed만 재참조해 우울·피로·불안·라포·회복감 5개 변주를 생성했다. 개발용 <code>VIGNETTE_VOICE_POC_SAMPLE_TTS</code> 플래그로 <code>/voice/ws</code> P1 sample TTS provider에도 연결했다.</p></div><div><b>산출물</b><p><code>docs/voice-art/bimec-persona-voice-poc-2026-06-27.md</code>, <code>docs/voice-art/p1-seoyeon-higgs-v3-20260627/manifest.json</code>, <code>apps/api/app/services/voice.py</code></p></div><div><b>검증</b><p>mp3/wav 6개(seed+5) 생성, 24kHz, 7.72~11.68초. live <code>/voice/ws</code> text_turn smoke: provider <code>p1-sample-poc</code>, binary 20 chunks/78,573 bytes. 샘플 provider는 TTS override만 담당하고 마이크/STT는 OpenAI 키가 필요하다. 프로덕션 탑재는 모델 라이선스·권리정책 결정 전 금지.</p></div></div>
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>barge-in / paralinguistic logging</b><p>음성 learner turn에 audio_ref, silence_ms, speech_rate, barge_in을 nullable 메타로 저장하고, 회기 리뷰에서 침묵·발화 속도·끼어듦·음성 입력 비언어 칩으로 표시한다. 실제 장시간 WSS/마이크와 한숨·울음 감지 증거는 남아 있다.</p></div><div><b>산출물</b><p><code>turns</code> nullable 확장, voice route 메타 산출, <code>ReviewNonverbalEvent</code>, <code>SessionReview</code> 칩 UI</p></div><div><b>검증</b><p>오디오 STT→엔진→TTS 더블 테스트에서 voice turn log에 메타 저장; session review E2E 1 passed; backend voice/session 17 passed</p></div></div>
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>s2s 2차 PoC 판단</b><p>캐스케이드 1차 원칙과 s2s 유지/폐기 증거 기준을 문서화했다. 채택 여부 결정은 윤찬 DECIDE로 유지한다.</p></div><div><b>산출물</b><p><code>docs/decisions/voice-s2s-poc.md</code></p></div><div><b>검증</b><p>transcript, safety, audit, latency, failure, privacy 기준 명시</p></div></div>
@ -763,18 +801,20 @@
</div>
</div>
<div class="tab-panel" id="panel-ops" role="tabpanel" aria-labelledby="tab-ops" hidden>
<div class="detail-head"><div><h3>운영 / 검증 상세</h3><p>실제 운영 도메인에 반영됐는지, 서버가 하나로 정리됐는지, 완료라고 말할 증거가 있는지 확인하는 탭이다.</p></div><div class="mini-metrics"><span>웹: 200</span><span>API: prod OK</span><span>Live2D: 404</span></div></div>
<div class="detail-head"><div><h3>운영 / 검증 상세</h3><p>실제 운영 도메인에 반영됐는지, 서버가 하나로 정리됐는지, 완료라고 말할 증거가 있는지 확인하는 탭이다.</p></div><div class="mini-metrics"><span>웹: 200</span><span>API: prod/dev OK</span><span>Compose smoke: OK</span></div></div>
<div class="task-list">
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>운영 웹 asset 확인</b><p>커스텀 도메인이 최신 JS/CSS asset을 서빙한다.</p></div><div><b>산출물</b><p><code>index-DEgzFAUm.js</code>, <code>index-BGRtWhu_.css</code></p></div><div><b>검증</b><p><code>Invoke-WebRequest https://vignette.chanpaca.net/login</code></p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>공개 API prod health</b><p>터널은 prod API 8001을 보고, API는 DB와 engine을 모두 확인한다.</p></div><div><b>산출물</b><p><code>environment:"prod"</code>, <code>db:true</code>, <code>engine:true</code></p></div><div><b>검증</b><p><code>https://api-vignette.chanpaca.net/health</code></p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>Tailscale Tailnet 런타임</b><p>로컬 web/api/engine을 Tailscale Serve로 묶어 같은 Tailnet PC·모바일에서 HTTPS로 접근한다. Vite proxy와 API 포트가 어긋나지 않도록 <code>dev-up.ps1</code>에서 <code>VITE_API_PROXY_TARGET</code><code>ApiPort</code>와 동기화한다. Tailnet dev-origin에서는 public OAuth redirect_uri가 로컬/Tailnet 세션이 아니라 공개 API 세션으로 돌아가므로 Google 버튼과 직접 Google 시작 URL을 <code>local_oauth_unavailable</code>로 차단하고 dev-login만 사용한다. Vite proxy는 원래 프론트 host/proto를 <code>X-Forwarded-Host</code>/<code>X-Forwarded-Proto</code>로 넘긴다.</p></div><div><b>산출물</b><p><code>scripts/start-tailscale-runtime.ps1</code>, <code>AUTH_DEV_LOGIN_EXTRA_ORIGINS</code>, <code>VITE_ALLOWED_HOSTS</code>, <code>Login.tsx</code>, <code>vite.config.ts</code>, <code>auth.py</code></p></div><div><b>검증</b><p><code>https://alpaca-home.taile93291.ts.net/login</code> 200, <code>/api/auth/config</code> dev_login true, dev-login 후 <code>/api/auth/me</code> 200, direct Google start → <code>/login?oauth=local_oauth_unavailable</code>, Tailnet auth/dev-login E2E 2 passed.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>운영 웹 asset 확인</b><p>커스텀 도메인의 로그인 페이지가 최신 production build asset을 정상 응답한다. 2026-06-28 11:05 KST 기준 Cloudflare Pages production deploy는 <code>24c90784</code>다.</p></div><div><b>산출물</b><p><code>https://vignette.chanpaca.net/login</code>, <code>assets/index-HjXyC0JT.js</code>, <code>assets/index-C8JKXhNP.css</code></p></div><div><b>검증</b><p><code>Invoke-WebRequest https://vignette.chanpaca.net/login</code> → 200 + latest assets</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>공개/API 런타임 health</b><p>터널은 prod API 8001을 보고, API는 DB와 engine을 모두 확인한다. 로컬 dev API 8010도 같은 엔진/DB 경로로 정상이다.</p></div><div><b>산출물</b><p><code>environment:"prod"</code>, <code>environment:"dev"</code>, <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code></p></div><div><b>검증</b><p><code>https://api-vignette.chanpaca.net/health</code>, <code>http://127.0.0.1:8001/health</code>, <code>http://127.0.0.1:8010/health</code> 모두 OK</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>Tailscale Tailnet 런타임</b><p>로컬 web/api/engine을 Tailscale Serve로 묶어 같은 Tailnet PC·모바일에서 HTTPS로 접근한다. Vite proxy와 API 포트가 어긋나지 않도록 <code>dev-up.ps1</code>에서 <code>VITE_API_PROXY_TARGET</code><code>ApiPort</code>와 동기화한다. Tailnet dev-origin에서는 공개 OAuth callback으로 세션이 섞이지 않게 dev-login을 기본 검증 경로로 둔다.</p></div><div><b>산출물</b><p><code>https://alpaca-home.taile93291.ts.net</code>, <code>scripts/start-tailscale-runtime.ps1</code>, <code>VITE_ALLOWED_HOSTS</code>, <code>vite.config.ts</code>, <code>auth.py</code></p></div><div><b>검증</b><p><code>/login</code> 200, <code>/api/auth/config</code> 200, <code>redirect_uri=https://api-vignette.chanpaca.net/auth/callback</code>, <code>dev_login_enabled:true</code>. Vite host-header block 재현 안 됨.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>Docker compose 패키징 smoke</b><p>더미 운영 env 기준 fresh compose 기동을 통과했다. API 컨테이너 import 실패, 구현 없는 RAG 사이드카, 로컬 <code>apps/api/.env</code> 이미지 혼입, startup owner-DDL, 빈 <code>admin_engine_config</code> 행 때문에 prod가 죽던 경로를 수정했다. 루트 계산은 <code>app.paths.repo_root()</code>로 모았고, backend requirements는 exact pin으로 고정했다.</p></div><div><b>운영 주의</b><p>패키징은 OK지만 실제 이식 전에는 배포지 <code>infra/.env</code><code>APP_DB_PASSWORD</code>, OAuth client id/secret, <code>OPENAI_API_KEY</code>, <code>SESSION_SECRET</code> 등 소유자 비밀값을 채워야 한다. <code>data/kb</code> live-coaching source pack은 commit 또는 같은 경로 provision이 필요하다. 현재 로컬 env의 <code>ENGINE_MODE=claude_p</code>와 prod 금지 샘플 TTS 플래그는 운영값으로 쓰면 안 된다.</p></div><div><b>검증</b><p><code>docker compose -f infra/docker-compose.yml config --quiet</code>, API/Web image build, API image <code>.env*</code> 미포함, prod import smoke, fresh <code>docker compose up -d --build</code><code>/api/health</code> 200, preflight <code>OK requirements_pinned/live_coach_kb/env_file</code>.</p></div></div>
<div class="task-row"><div><span class="task-status s-doing">BLOCKED</span></div><div><b>공개 vnet.18ka.net 런타임</b><p><code>vnet.18ka.net</code><code>api-vnet.18ka.net</code>을 정식 공개 도메인으로 허용하도록 코드 경로는 보강했다.</p></div><div><b>산출물</b><p><code>FRONTEND_ORIGIN_MAP</code>, 공개 host별 API 매핑, <code>scripts/start-public-runtime.ps1</code> vnet ingress 옵션</p></div><div><b>블로커</b><p>현재 DNS 미해결, Cloudflare credential의 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>서버 정리 상태</b><p>운영 API와 로컬/Tailnet 테스트 API를 구분해 유지한다.</p></div><div><b>산출물</b><p>8001 prod API, 8000 local API, 8010 Tailnet API, 5173 web, 9099 engine, 20241 cloudflared</p></div><div><b>검증</b><p><code>Get-NetTCPConnection</code> 대상 포트 확인</p></div></div>
<div class="task-row"><div><span class="task-status s-doing">CHECK</span></div><div><b>재부팅 후 런타임 재기동</b><p>engine gateway, prod API, cloudflared를 health-check하고 복구하는 watchdog과 Scheduled Task installer를 추가했다. 아직 DNS가 없는 <code>api-vnet.18ka.net</code>은 기본 watchdog 검사에서 제외해 불필요한 restart loop를 막고, 실제 개통 후 <code>-AdditionalPublicHealthUrls</code>로 명시 추가한다. 실제 재부팅 후 public <code>/turn</code> smoke는 남아 있다.</p></div><div><b>산출물</b><p><code>watch-public-runtime.ps1</code>, <code>install-public-runtime-task.ps1</code>, ops runbook</p></div><div><b>검증</b><p>PowerShell parser OK, check-only watchdog healthy: engine/api/web-preview/cloudflared/public-api</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 감사팀 / 전면 레이아웃 개선</b><p>서브에이전트 전담으로 1차 overflow/clipping 수리 후 2차 전면 재설계를 진행했고, 이번 라운드에서 7개 권장 너비 시각 수용까지 닫았다. 세션, 학습자 홈/리뷰, 교수 콘솔, 관리자, 설정 화면을 작업 화면 기준으로 재배치했고, 과한 빈칸·튀어나감·버튼/텍스트 잘림·모바일 테이블 사용성을 줄였다. 엄격한 시각 게이트 + 화면별 적대적 시각 재검수에서 7개 화면 모두 accept(critical/major 0, 회귀 0)를 받아 DONE으로 닫는다.</p></div><div><b>산출물</b><p><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code>, page-by-page redesign patches, <code>apps/web/e2e/layout-visual-gate.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 엄격 시각 게이트 7 passed, layout focused E2E desktop/mobile 54 passed, 적대적 시각 재검수 7/7 accept·회귀 0.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>엄격한 레이아웃 시각 게이트 구축 · 병렬 수정 · 적대적 재검수</b><p>핸드오프가 요구한 7개 권장 너비(390/720/861/900/1024/1280/1440) 시각 수용을 자동 게이트로 고정했다. 7개 재설계 화면을 각 너비에서 렌더링해 가로 overflow 0, 컨트롤 하드클립/텍스트클립 0을 강제하고 화면당 7장 총 49장 풀페이지 스크린샷을 남긴다. 화면별 1에이전트 병렬 시각 리뷰로 49장 직접 판독 → 6개 병렬 수정 에이전트(파일 비중첩)가 결함 수정 → 화면별 적대적 재검수로 해소·무회귀 확정.</p></div><div><b>산출물</b><p><code>apps/web/e2e/layout-visual-gate.spec.ts</code>, <code>node_modules/.tmp/layout-gate/*.png</code> 49장, 2× workflow(review/verify)·6× fix agent</p></div><div><b>검증</b><p>게이트 7 passed(재스크린샷). 해소된 핵심 결함: 학습자홈 1280/1440 3열 가운데 컬럼 붕괴(critical → 2열 분기 상향 + word-break:keep-all), 교수 'API 404' raw 배너 제거, 리뷰 빈상태 위계 역전, 관리자 스켈레톤, 설정 와이드 좌측 데드존 제거, 세션 모바일 44px 터치타깃. 적대적 재검수 7/7 accept, regression 0.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>레이아웃 cosmetic minor 폴리시 일괄 처리</b><p>적대적 재검수 잔여 cosmetic minor를 5개 병렬 폴리시 에이전트로 처리했다. 학습자홈 헬퍼문구 고아 글자, 세션 1024 일시정지 아이콘 정리 + 보내기 버튼 대비 강화, 설정 2x2 칩 행 높이 균일, 교수 검토 큐 카드 갭 제거, 관리자 2열 높이 동기화를 적용했다.</p></div><div><b>산출물</b><p>page-by-page polish patches(LearnerHome/Session/Settings/Professor/Admin)</p></div><div><b>검증</b><p><code>npm run typecheck</code> OK, 시각 게이트 7 passed, 레이아웃 E2E desktop/mobile 54 passed, session-layout 8/8 — 회귀 0.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 다크 테마 SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 <code>lib/theme.ts</code> 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다. API 기본 preference <code>system</code>은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따르며, 회기 리뷰·설정 포함 주요 페이지의 흰 섹션 잔재를 dark 작업면으로 맞췄다. P1 실제 세션 기본 아바타는 긴 머리 PSB 대신 컨셉 보드 크롭 <code>p1-concept</code>로 임시 전환했고, active session 스테이지 상태 배지는 아바타와 겹치지 않는 3행 구조로 정리했다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>session.css</code>, <code>session-review.css</code>, <code>Settings.tsx</code>, <code>settings.css</code>, <code>lib/theme.ts</code>, <code>settings/useTheme.ts</code>, <code>apps/web/public/avatar/p1-concept/neutral.png</code>, <code>avatar-expression.spec.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p><code>npm run typecheck</code>, <code>npm run build</code>, <code>npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts e2e/avatar-expression.spec.ts e2e/session-review.spec.ts e2e/settings.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run --workers=1</code> 36 passed. 7개 주요 화면 x 3뷰포트 캡처 21장에서 overflow 0, 큰 흰 CSS 배경면 0.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>회기 아카이브 저장/복원 API</b><p><code>/learn/history</code><code>보관됨</code>을 실제 학습자별 저장 상태로 연결했다. 종료 회기는 <code>POST /sessions/{id}/archive</code>/<code>restore</code>로 보관·복원하고, <code>app.session_archive_state</code>는 보기 상태만 저장한다. 진행 중 회기는 보관하지 못하며, 보관은 transcript/review/share/audit evidence를 삭제하지 않는다.</p></div><div><b>산출물</b><p><code>SessionArchiveResponse</code>, <code>LearnerSessionSummary.archived</code>, <code>LearnerDashboardOverview.archived_sessions</code>, <code>session_archive_state</code> RLS, <code>LearnerHome</code> 보관/복원 버튼</p></div><div><b>검증</b><p><code>python -B -m pytest app/ -q</code> 168 passed, <code>python -B -m pytest engine_gateway/ -q</code> 9 passed, <code>npm run check:api-types</code>, <code>npm run typecheck</code>, <code>npm run build</code>, <code>npx playwright test e2e/learner.spec.ts --project=chromium-desktop --workers=1</code> 6 passed.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>세션 종료 UX와 다크 테마 SSOT</b><p>세션 하단의 드래그형 종료 컴포넌트를 명시 확인 다이얼로그로 바꿔 모바일 오발동과 제스처 실패를 줄였다. Topbar/Settings의 theme 저장·초기화는 <code>lib/theme.ts</code> 단일 경로로 합치고, 저장값이 없으면 dark 기본값을 앱 부팅 전에 적용한다. API 기본 preference <code>system</code>은 Settings에서 light로 오해하지 않고 현재 초기 테마를 따른다. P1 실제 세션 기본 아바타는 사용자 제공 PSD 기반 <code>seoyeon-live2d-psd-v2</code>이며, sad 표정에서 울상 눈썹·우는 입·눈물 파츠를 합성한다. active session 스테이지 상태 배지는 아바타와 겹치지 않는 3행 구조로 정리했다.</p></div><div><b>산출물</b><p><code>Session.tsx</code>, <code>RasterBust.tsx</code>, <code>AvatarPreview.tsx</code>, <code>session.css</code>, <code>apps/web/public/avatar/seoyeon-live2d-psd-v2/parts/*.png</code>, <code>docs/avatar-art/seoyeon/live2d-psd-v2/</code>, <code>avatar-expression.spec.ts</code>, <code>session-layout.spec.ts</code></p></div><div><b>검증</b><p>PSD 파츠 추출 53개. <code>preview-sad.png</code>에서 눈물/우는 입/울상 눈썹 확인. 기존 시각 회귀 게이트는 후속 전체 검증에서 재실행한다.</p></div></div>
<div class="task-row"><div><span class="task-status s-done">DONE</span></div><div><b>잔여(비차단) — 공용 셸 단일 항목 → 처리</b><p>축소 사이드바 세로 구분선이 본문 전체 높이까지 닿지 않던 건을 <code>components/shell/shell.css</code>에서 처리했다. <code>.vg-nav border-right</code> 제거 후 <code>.vg-shell__body</code> 컨테이너 배경 하어라인으로 본문 그리드 전체 높이 구분선을 그리고, 그리드·구분선 폭을 <code>--nav-cur</code>로 동기화. learner-home 로딩 스켈레톤 밀도도 실제 카드 구조 모사로 보강했다.</p></div><div><b>판정</b><p>구현 완료. 검증: <code>npm run typecheck</code> PASS + <code>vite build</code> PASS. 전 페이지 시각 회귀 게이트는 web+api+DB 스택으로 이 워크스테이션 미실행 — 스택 가용 시 1회 시각 확인 권장.</p></div></div>
</div>
<div class="tab-subhead"><h4>검증 실행 상세 표</h4><p>요약하지 않고 실행 범위, 명령, 결과를 행 단위로 남긴다.</p></div>
@ -782,30 +822,36 @@
<thead><tr><th>범위</th><th>명령 / 확인 대상</th><th>결과</th></tr></thead>
<tbody>
<tr><td>Web typecheck</td><td><code>npm run typecheck</code></td><td>Passed</td></tr>
<tr><td>Full Playwright E2E</td><td><code>PLAYWRIGHT_PORT=5174 npm run e2e</code></td><td>113 passed; desktop/mobile 병렬 + <code>@single-run</code> DB 영속화·음성 성공 경로까지 전체 통과.</td></tr>
<tr><td>API typegen SSOT</td><td><code>npm run check:api-types</code></td><td>Passed; FastAPI OpenAPI → <code>src/lib/api.gen.ts</code> stale check</td></tr>
<tr><td>Contract SSOT aggregate DTO</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>session persistence</code> / <code>learner + session-review + teacher + layout gate</code></td><td>Passed; learner sessions, session review/worksheet, teacher dashboard, session start/detail DTOs now use generated <code>ApiSchema</code> aliases with UI fallback. Stage responses are OpenAPI enum unions. Focused E2E 32 passed. <code>.github/workflows/api-contract.yml</code> now runs <code>npm run check:api-types</code> on API/Web contract changes.</td></tr>
<tr><td>Backend pytest</td><td><code>python -m pytest app/ -q</code></td><td>130 passed</td></tr>
<tr><td>SEO/GEO share cards</td><td><code>pytest app/test_session_share.py app/test_session_turn_persistence.py -q</code> / <code>npm run generate:api-types</code> / <code>npm run typecheck</code></td><td>21 passed; session share creates hashed-token public unfurl payload without raw transcript, revoked token returns 404, OpenAPI generated share DTOs, review screen share button typechecks. Static <code>robots.txt</code>/<code>sitemap.xml</code>/<code>llms.txt</code> added.</td></tr>
<tr><td>Backend pytest</td><td><code>python -m pytest app/ -q</code></td><td>145 passed</td></tr>
<tr><td>X2 evaluator model routing</td><td><code>python -m pytest app/test_evaluator_model_routing.py app/test_runtime_policy.py -q</code></td><td>23 passed; fast/deep evaluator model override and blank-default routing covered</td></tr>
<tr><td>C1 case worksheet</td><td><code>python -m pytest app/test_session_turn_persistence.py app/test_rbac_idor.py -q</code> / <code>npm run check:api-types</code> / <code>npm run typecheck</code> / <code>npm run build</code> / local API smoke</td><td>25 passed; session review exposes transcript-grounded <code>caseWorksheet</code>, learner-edited <code>saved_by_learner</code> worksheet persists through <code>app.case_worksheet</code>, and evaluator-only turns stay hidden. Local smoke returned <code>saved_by_learner:local smoke saved worksheet</code>. Stage labels are normalized at response boundaries.</td></tr>
<tr><td>M3 auth claim mapping</td><td><code>python -m pytest app/test_auth_providers.py -q</code></td><td>22 passed; Google/SAML cohort maps, provider external_id, provider error reason covered</td></tr>
<tr><td>CrisisGate 109</td><td><code>python -m pytest app/test_session_turn_persistence.py app/test_voice_ws.py -q</code></td><td>20 passed; real crisis stops before engine, returns 109 resource, safety event DB insert payload covered</td></tr>
<tr><td>Professor learner growth</td><td><code>python -m pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q</code></td><td>21 passed; teacher dashboard exposes learner_growth and safety alert queue</td></tr>
<tr><td>Persona authoring CRUD</td><td><code>python -m pytest app/test_persona_review.py -q</code></td><td>19 passed; teacher/admin draft create/read/update/review submit, learner blocked, approved-only catalog maintained</td></tr>
<tr><td>Professor review workflow</td><td><code>python -m pytest app/test_rbac_idor.py app/test_teacher_dashboard.py -q</code> / <code>npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1</code></td><td>9 backend passed + 4 E2E passed; teacher dashboard exposes learner_growth/safety queues and review-pending/recent rows now open <code>/teach/session/:id/review</code> read-only with learner worksheet editing blocked for teachers.</td></tr>
<tr><td>Learner dashboard personalization</td><td><code>pytest app/test_learner_dashboard.py app/test_teacher_dashboard.py app/test_rbac_idor.py -q</code> / <code>npm run check:api-types</code> / <code>npm run typecheck</code> / <code>npm run build</code></td><td>12 backend passed; <code>GET /sessions/dashboard</code> uses shared <code>session_metrics</code> and feeds LearnerHome overview/growth/persona_progress/recent_feedback/last-session recap. Focused learner/review/teacher/layout/session E2E evidence recorded in backlog.</td></tr>
<tr><td>Teacher review status</td><td><code>pytest app/test_teacher_dashboard.py app/test_rbac_idor.py app/test_learner_dashboard.py -q</code> / <code>npm run typecheck</code> / <code>npm run build</code> / <code>teacher.spec.ts</code></td><td>12 backend passed + 4 E2E passed; <code>app.session_review_status</code>, teacher dashboard <code>review_status/review_note/reviewed_at</code>, and <code>PUT /teacher/sessions/{session_id}/review-status</code> are documented. 검토 큐 visible 회귀와 교수자 전용 review grid 영역도 테스트로 고정했다.</td></tr>
<tr><td>Persona authoring CRUD</td><td><code>python -m pytest app/test_persona_review.py -q</code></td><td>31 passed; teacher/admin draft create/read/update/review submit, approved persona revision clone, code-family archive delete, learner blocked, approved-only catalog maintained</td></tr>
<tr><td>P1 cost telemetry</td><td><code>app.test_session_turn_persistence.SessionTurnPersistenceTest</code></td><td>Passed; generate/stream client AI turns store provider/model/tokens/cost</td></tr>
<tr><td>Engine stream contract</td><td><code>gateway token/done/error parsing</code></td><td>Passed; gateway error event does not create saved turns</td></tr>
<tr><td>Engine session reuse</td><td><code>engine_gateway.test_gateway_model</code></td><td>7 tests OK; live session_id reuse and ephemeral close fixed</td></tr>
<tr><td>Engine session reuse</td><td><code>engine_gateway.test_gateway_model</code></td><td>9 tests OK; live session_id reuse and ephemeral close fixed</td></tr>
<tr><td>P1 masking gate</td><td><code>app.test_orchestrator_masking</code></td><td>4 tests OK; phone/email/RRN absent from generate/stream payloads</td></tr>
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>7 tests OK; other learner 403, read_session audit, evaluator-only hidden</td></tr>
<tr><td>P2a RBAC/audit/visibility</td><td><code>app.test_rbac_idor</code></td><td>8 tests OK; other learner 403, read_session audit, evaluator-only hidden, teacher session review read allowed while learner worksheet write remains 403</td></tr>
<tr><td>Admin access delegation</td><td><code>python -m pytest app/test_auth_providers.py app/test_admin_ops.py -q</code> / <code>npm run typecheck</code> / <code>npm run check:api-types</code></td><td>32 backend passed; <code>admin_access</code> and <code>super_admin</code> are exposed through auth/admin DTOs. Super admins can grant/revoke admin page access; configured super admins stay protected. Frontend role switching uses <code>canAccessRole</code>.</td></tr>
<tr><td>Admin usage persistence</td><td><code>python -m pytest app/test_admin_ops.py app/test_runtime_policy.py -q</code> / authenticated local public-API smoke</td><td>26 passed; <code>/admin/usage</code> returns 200 with <code>source=database</code>, <code>durable=true</code>. Cloudflare blocked raw Python public smoke with 1010, so app-level HTTP was verified against <code>127.0.0.1:8001</code> using the same prod process.</td></tr>
<tr><td>Turn evaluation SSOP</td><td><code>pytest app/test_session_turn_persistence.py app/test_evaluation_persistence.py app/test_voice_ws.py</code></td><td>20 tests OK; submit/stream/voice fast-loop evaluation normalized mapping + evaluator-only raw RLS + turn insert id 확인</td></tr>
<tr><td>Postgres RLS/audit smoke checker</td><td><code>python scripts\check-postgres-rls-audit.py --help</code></td><td>py_compile/help/safe default OK; live DSN run still required for proof</td></tr>
<tr><td>OAuth/BFF cookie security</td><td><code>app.test_auth_providers</code></td><td>20 tests OK; PKCE/state, cookie-bound signed state restart recovery, state cookie mismatch reject, opaque HttpOnly/Secure cookie, no browser tokens, SAML AuthnRequest/dev-only fixture ACS, dev/Tailnet OAuth guard, public-origin dev-login closed</td></tr>
<tr><td>H4 consent gate</td><td><code>pytest app/test_auth_providers.py app/test_session_turn_persistence.py app/test_persona_review.py app/test_voice_ws.py -q</code></td><td>64 passed; learner consent accept/withdraw, non-learner/unaccepted reject, no-consent session start 403 <code>consent_required</code>, voice dev persona consent guard, frontend generated API contract updated</td></tr>
<tr><td>Persona review workflow</td><td><code>app.test_persona_review</code></td><td>19 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approve/reject audit, learner 403</td></tr>
<tr><td>Voice WS contract</td><td><code>app.test_voice_ws</code></td><td>5 tests OK; auth guard, audio_start/chunks/audio_end, text_turn, ping, max audio cap</td></tr>
<tr><td>Voice preset/EOT</td><td><code>app.test_voice_service</code></td><td>11 tests OK; TTS payload, preset fallback, P1 sample TTS, EOT readiness</td></tr>
<tr><td>Persona review workflow</td><td><code>app.test_persona_review</code></td><td>31 tests OK; approved-only catalog, teacher/admin queue, draft authoring, approved persona revision, archive audit, learner 403</td></tr>
<tr><td>Voice WS contract</td><td><code>app.test_voice_ws</code></td><td>8 tests OK; auth guard, audio_start/chunks/audio_end, text_turn, ping, max audio cap, DB persona voice map binding, explicit preset override</td></tr>
<tr><td>Voice preset/EOT</td><td><code>app.test_voice_service</code></td><td>14 tests OK; TTS payload, preset fallback, DB voice-map resolver, unsupported provider fallback, P1 sample TTS, EOT readiness</td></tr>
<tr><td>Voice metadata</td><td><code>app.test_session_turn_persistence</code></td><td>audio_ref/silence_ms/speech_rate/barge_in persisted on learner voice turn; review <code>nonverbal</code> events exposed</td></tr>
<tr><td>P1 MVP UI E2E</td><td><code>npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run</code></td><td>1 passed</td></tr>
<tr><td>Persona review UI E2E</td><td><code>npx playwright test e2e/teacher.spec.ts --project=chromium-single-run</code></td><td>1 passed; teacher approves pending persona from console</td></tr>
<tr><td>Persona/session review UI E2E</td><td><code>npx playwright test e2e/teacher.spec.ts --project=chromium-single-run</code></td><td>2 passed; teacher approves pending persona and opens pending ended-session review detail from console</td></tr>
<tr><td>Voice UI synthetic E2E</td><td><code>npx playwright test e2e/voice-success.spec.ts --project=chromium-single-run</code></td><td>2 passed; direct WS cascade plus Session mic button path with synthetic browser audio</td></tr>
<tr><td>Voice s2s decision memo</td><td><code>docs/decisions/voice-s2s-poc.md</code></td><td>criteria recorded; keep/drop decision remains owner DECIDE</td></tr>
<tr><td>Hanshin data/SSO gate</td><td><code>docs/ops/hanshin-data-governance-gate.md</code></td><td>artifact created; written external evidence still required</td></tr>
@ -813,25 +859,29 @@
<tr><td>Python compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
<tr><td>Phase 3 artifact gates</td><td><code>python scripts\check-phase3-artifacts.py --help</code> + <code>pytest app/test_phase3_artifact_checker.py -q</code></td><td>Passed; checker now enforces CSV enums, KPI metric required fields, approved export PII/agreement/consent/withdrawal/file-hash gates. Actual pilot evidence still external.</td></tr>
<tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr>
<tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td><code>12da49f3</code>, source <code>0ec266a</code></td></tr>
<tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login</code></td><td><code>index-DEgzFAUm.js</code>, <code>index-BGRtWhu_.css</code></td></tr>
<tr><td>Pages production deploy</td><td><code>wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true</code></td><td><code>24c90784</code>, dirty local source deploy</td></tr>
<tr><td>Custom domain assets</td><td><code>https://vignette.chanpaca.net/login</code></td><td><code>index-HjXyC0JT.js</code>, <code>index-C8JKXhNP.css</code></td></tr>
<tr><td>Legacy Live2D routes</td><td><code>/live2d/mao/* / /live2d/haru/* / /live2d/live2dcubismcore.min.js</code></td><td>404</td></tr>
<tr><td>Compose</td><td><code>docker compose -f infra\docker-compose.yml --env-file infra\.env.example config --quiet</code></td><td>Passed with dummy required env</td></tr>
<tr><td>Compose template</td><td><code>docker compose -f infra\docker-compose.yml config --quiet</code></td><td>Template path is valid with dummy required env. API build context now uses repo root; stale <code>rag.server</code> sidecar removed.</td></tr>
<tr><td>Docker image smoke</td><td><code>docker build -f apps/api/Dockerfile .</code> / <code>docker run ... python -c "import app.main"</code> / <code>docker build -f apps/web/Dockerfile apps/web</code></td><td>API/Web image build and API import smoke pass after packaging cleanup. Web build uses npm lockfile and ignores host <code>node_modules</code>; API image excludes local <code>.env</code> files.</td></tr>
<tr><td>Deploy preflight</td><td><code>python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets</code></td><td>Passed: exact-pinned API requirements, live coaching <code>data/kb</code> source pack, env template keys. DB mode can additionally check app-role DSN with <code>--require-app-role</code>.</td></tr>
<tr><td>Fresh compose smoke</td><td><code>docker compose -p vignette-packaging-smoke -f infra/docker-compose.yml up -d --build</code> + proxy <code>/api/health</code></td><td>Passed with dummy production-safe env: API healthy, DB healthy, web/proxy up, <code>http://localhost:18080/api/health</code> 200 with <code>db:true</code>, <code>engine:true</code>, <code>engine_mode:"claude_cli"</code>. Smoke volumes/network removed after run.</td></tr>
<tr><td>Actual deployment env</td><td><code>infra/.env</code> owner-secret fill-in</td><td>Remaining external step: deployment target must provide real <code>APP_DB_PASSWORD</code>, OAuth client id/secret, <code>OPENAI_API_KEY</code>, <code>SESSION_SECRET</code>, and production-safe engine/voice flags. Current local stray values such as <code>ENGINE_MODE=claude_p</code> and prod sample TTS must not be copied.</td></tr>
<tr><td>Focused E2E</td><td><code>admin + db-persistence + voice-success</code></td><td>10 passed</td></tr>
<tr><td>Admin manage-users</td><td><code>admin.spec.ts --grep manage real server-known users</code></td><td>desktop/mobile 2 passed</td></tr>
<tr><td>Full E2E</td><td><code>PLAYWRIGHT_HOST=127.0.0.1 npx playwright test</code></td><td>72 passed</td></tr>
<tr><td>Full E2E</td><td><code>PLAYWRIGHT_PORT=5174 npm run e2e</code></td><td>113 passed</td></tr>
<tr><td>Public auth discovery</td><td><code>E2E_PUBLIC_AUTH=1 npx playwright test --list --project=chromium-public-auth</code></td><td>2 tests listed</td></tr>
<tr><td>Public readiness</td><td><code>public-auth-turn.spec.ts --grep production-safe</code></td><td>1 passed</td></tr>
<tr><td>Persona auth boundary</td><td><code>GET /personas</code></td><td>verified local/public unauth 401</td></tr>
<tr><td>Public login</td><td><code>auth.spec.ts --grep public login</code></td><td>1 passed</td></tr>
<tr><td>Public runtime scripts</td><td><code>start/watch/install-public-runtime*.ps1</code></td><td>parser OK; watchdog check-only healthy. Default public checks exclude not-yet-live <code>api-vnet.18ka.net</code>; add future domains explicitly with <code>-AdditionalPublicHealthUrls</code>.</td></tr>
<tr><td>Public API health</td><td><code>https://api-vignette.chanpaca.net/health</code></td><td>prod, db true, engine true</td></tr>
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>http://localhost:5173/login</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-27 18:57 KST 재확인: Tailnet login 200, env 없는 임시 Vite host-header smoke 200, <code>/api/health</code> dev/db/engine true, <code>/api/auth/config</code> dev_login true. <code>alpaca-home.taile93291.ts.net</code>는 Vite 기본 <code>allowedHosts</code>에도 포함해 스크립트 밖 수동 Vite 기동 시 403 재발 가능성을 낮췄다. 기존 public 재확인: public login 200, public API health prod/db/engine true, public Google start 302 to <code>accounts.google.com</code> with HttpOnly state cookie, provider callback error → <code>/login?oauth=access_denied</code>. Docker <code>vignette-dev-db</code> accepting connections, 기존 컨테이너 healthcheck 없음.</td></tr>
<tr><td>Public/local/Tailnet login recovery</td><td><code>https://vignette.chanpaca.net/login</code> / <code>https://api-vignette.chanpaca.net/health</code> / <code>https://alpaca-home.taile93291.ts.net/login</code></td><td>2026-06-28 current smoke: public login 200 (<code>assets/index-HjXyC0JT.js</code>, <code>assets/index-C8JKXhNP.css</code>), public API health prod/db/engine true, unauth public <code>/personas</code> 401, local 8001 prod health true, local 5174 public preview 200, cloudflared tunnel running. Vite host block/Cloudflare 502 not reproduced.</td></tr>
<tr><td>Local 5175 login</td><td><code>PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 auth.spec.ts</code></td><td>desktop/mobile passed</td></tr>
<tr><td>Learner/readiness E2E</td><td><code>learner.spec.ts + readiness.spec.ts desktop/mobile</code></td><td>14 passed</td></tr>
<tr><td>Learner screenshots</td><td><code>learn-empty-desktop.png / learn-empty-mobile-compact.png</code></td><td>document overflow 0, empty history visible</td></tr>
<tr><td>Session layout/turn</td><td><code>session-layout.spec.ts desktop/mobile</code></td><td>8 passed</td></tr>
<tr><td>Session end + dark theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>session-layout</code>, <code>avatar-expression</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; Playwright 묶음 36 passed. 7개 주요 화면 x 3뷰포트 수동 캡처 갱신(<code>docs/design-verification/full-pages</code>)도 overflow 0, 큰 흰 CSS 배경면 0. P1 active session은 <code>rasterArtSet="p1-concept"</code>, 상태 배지/아바타 겹침 없음. Settings now treats API <code>system</code> preference as current initial theme instead of forcing light.</td></tr>
<tr><td>Session end + dark theme UX</td><td><code>npm run typecheck</code>, <code>npm run build</code>, <code>layout-visual-gate</code>, <code>session-layout</code>, <code>avatar-expression</code>, <code>settings</code>, <code>session-review</code></td><td>typecheck/build passed; Playwright 묶음 36 passed. P1 active session은 <code>rasterArtSet="seoyeon-live2d-psd-v2"</code>를 사용한다. sad 표정은 PSD 기반 눈물/우는 입/울상 눈썹 파츠를 렌더한다. Settings now treats API <code>system</code> preference as current initial theme instead of forcing light.</td></tr>
<tr><td>Voice/session focused</td><td><code>voice + voice-success + session-layout</code></td><td>15 passed</td></tr>
<tr><td>Layout redesign handoff</td><td><code>docs/ops/layout-redesign-handoff-2026-06-26.md</code></td><td>subagent scopes, files, verification, remaining visual review recorded</td></tr>
<tr><td>Layout redesign focused E2E</td><td><code>npx playwright test e2e/learner.spec.ts e2e/session-layout.spec.ts e2e/session-review.spec.ts e2e/admin.spec.ts e2e/settings.spec.ts e2e/teacher.spec.ts --project=chromium-desktop --project=chromium-mobile</code></td><td>54 passed after isolated Admin tablet flake rerun</td></tr>
@ -843,9 +893,12 @@
<tr><td>Adversarial visual verify</td><td><code>workflow layout-visual-verify (7 agents)</code></td><td>7/7 accept; critical/major resolved, regression 0; minor polish backlog recorded</td></tr>
<tr><td>Engine config 운영값</td><td><code>GET /admin/engine-config</code></td><td>claude_cli / http://127.0.0.1:9099 / gateway-default, durable, source=database (의도값 일치)</td></tr>
<tr><td>Engine gateway probe (live)</td><td><code>scripts/probe-engine-gateway.py --base-url http://127.0.0.1:9099 --reuse-runs 2</code></td><td>claude_p / opus-4-8; 단일 session_id 2회 reused stream; TTFT 16674199ms; cost 누적 0.068→0.123(turns 1→2)</td></tr>
<tr><td>Engine gateway RSS (live)</td><td><code>docs/ops/engine-rss-smoke-2026-06-28.md</code></td><td>gateway session 1개, <code>health.sessions=1</code>; gateway PID 51592 RSS 16.2MB; resident <code>claude -p</code> child PID 51688 RSS 363.3MB / Private 628.8MB after one turn; plugin bun children RSS 178.6MB.</td></tr>
<tr><td>Postgres RLS/audit smoke (live)</td><td><code>VIGNETTE_RLS_AUDIT_DSN=…vignette_app… check-postgres-rls-audit.py --write-fixtures</code></td><td>5 checks PASS: preflight, learner 세션/턴 격리, teacher cohort 스코프, visible_to evaluator-only 숨김, teacher/admin read audit insert (NOBYPASSRLS app role, fixtures 자동 정리)</td></tr>
<tr><td>Turn cost telemetry (live DB)</td><td><code>SELECT … FROM app.turns WHERE actor_kind='client_ai'</code></td><td>13 client_ai turns 적재: llm_provider=claude_cli, model=gateway-default, cost_usd 실값(합 $1.22, 평균 $0.094), visible_to 배열. 토큰은 CLI 게이트웨이 특성상 0, cost는 산출</td></tr>
<tr><td>Cosmetic polish re-verify</td><td><code>5 polish agents → typecheck + gate + layout E2E re-run</code></td><td>typecheck OK; gate 7 passed; layout focused E2E 54 passed; session-layout 8/8; regression 0</td></tr>
<tr><td>Dashboard layout alignment</td><td><code>npm run typecheck</code> / <code>npm run build</code> / <code>layout-visual-gate</code> / <code>session-layout</code> / <code>learner+teacher+admin E2E</code></td><td>typecheck/build passed; layout gate 7 passed; session-layout desktop/mobile 8 passed; learner+teacher+admin chromium-desktop 17 passed. 대표 캡처는 <code>docs/ops/layout-research-2026-06-28/final-gate-*.png</code>.</td></tr>
<tr><td>Learner session archive</td><td><code>python -B -m pytest -p no:cacheprovider app/ -q</code> / <code>python -B -m pytest -p no:cacheprovider engine_gateway/ -q</code> / <code>npm run check:api-types</code> / <code>npm run typecheck</code> / <code>npm run build</code> / <code>npx playwright test e2e/learner.spec.ts --project=chromium-desktop --workers=1</code></td><td>168 backend passed + 9 gateway passed; OpenAPI generated archive DTOs are current; learner history can archive/restore an ended session and keeps archived sessions out of the review queue while preserving records.</td></tr>
</tbody>
</table>
<div class="tab-subhead"><h4>다음 실행 명령</h4><p>남은 public OAuth <code>/turn</code> proof 확보용 명령이다.</p></div>
@ -859,8 +912,8 @@ $env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json"
<pre>cd D:\workspace\vignette
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1</pre>
</div>
<div class="source-note"><b>닫은 항목(live 증거):</b> ① engine config 운영값(<code>claude_cli / 9099 / gateway-default</code>, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 16674199ms, cost 누적 0.068→0.123. ③ Postgres RLS/audit smoke 5 checks PASS. ④ turn cost telemetry — <code>app.turns</code> client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). 레이아웃 시각 게이트 7/7 + 적대적 재검수 7/7 accept.</div>
<div class="source-note"><b>환경 제약으로 이 세션에서 못 닫는 항목(정직 표기):</b> 공개 Google OAuth 실제 <code>/turn</code>(owner 지시로 보류) · <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> 공개 런타임(DNS 미해결, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner 지시로 보류) · 음성 live STT/TTS provider·물리 마이크·공개 WSS 50분 · 저항엔진 openness 곡선 DB 실증 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 6건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: <code>docs/ops/backlog-2026-06-26.md</code>(B1 폴리시 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).</div>
<div class="source-note"><b>닫은 항목(live 증거):</b> ① engine config 운영값(<code>claude_cli / 9099 / gateway-default</code>, durable, db). ② 상주 엔진풀 probe — 단일 session_id 2회 reused stream, TTFT 16674199ms, cost 누적 0.068→0.123. ③ 상주 엔진풀 RSS — gateway session 1개에서 <code>claude -p</code> child RSS 363.3MB, gateway RSS 16.2MB. ④ Postgres RLS/audit smoke 5 checks PASS. ⑤ turn cost telemetry — <code>app.turns</code> client_ai 13행에 provider/model/cost_usd 실적재(합 $1.22). 레이아웃 시각 게이트 7/7 + 적대적 재검수 7/7 accept.</div>
<div class="source-note"><b>환경 제약으로 이 세션에서 못 닫는 항목(정직 표기):</b> 공개 Google OAuth 실제 <code>/turn</code>(실제 계정 storageState 필요) · <code>infra/.env</code> compose 필수값(<code>APP_DB_PASSWORD</code>, <code>OAUTH_GOOGLE_CLIENT_ID</code>, <code>OAUTH_GOOGLE_CLIENT_SECRET</code>) · <code>vnet.18ka.net</code>/<code>api-vnet.18ka.net</code> 공개 런타임(DNS 미해결, Cloudflare 18ka.net zone 권한 불일치, Google OAuth redirect URI 등록 필요) · 한신대 데이터/SSO 거버넌스(owner/기관 written evidence 필요) · 음성 live STT/TTS provider·물리 마이크·공개 WSS 50분 · 저항엔진 openness 곡선 DB 실증 · claude_cli↔Messages API 폴백 동일성 · 재부팅 후 watchdog smoke · Phase 3 파일럿 게이트 · decisions 6건(owner 결정). 운영 원칙상 가짜 증거로 DONE 표기하지 않는다. 전체 추적: <code>docs/ops/backlog-2026-06-26.md</code>(B1 폴리시 · B2 환경제약 · B3 소유자결정 · B4 거버넌스).</div>
</div>
<div class="tab-panel" id="panel-decisions" role="tabpanel" aria-labelledby="tab-decisions" hidden>
<div class="detail-head"><div><h3>윤찬 결정 필요 상세</h3><p>람다가 단독으로 정하면 월권인 항목이다. 결정되기 전에는 "완료"가 아니라 조건부로 둔다.</p></div><div class="mini-metrics"><span>결정 6개</span><span>조건부 GO 사유</span></div></div>

View file

@ -36,7 +36,7 @@ vignette/
│ │ ├─ app/ 라우터·서비스·스토어·DB·인증
│ │ └─ engine_gateway/ 별도 서비스: claude -p 상주 풀 / provider 어댑터
│ └─ web/ React 19 + Vite + Playwright 프론트
├─ infra/ docker-compose(db+api), DB 초기화 SQL
├─ infra/ docker-compose(db+api+web+proxy), DB 초기화 SQL
├─ docs/ 설계·운영 문서 (이 파일 포함)
└─ scripts/ 운영 스크립트(PowerShell 등)
```
@ -80,7 +80,7 @@ vignette/
`lifespan`(startup/shutdown)에서:
1. `init_pool()` → DB 풀 생성, `ensure_runtime_tables()` / `ensure_review_tables()` 보장.
2. `settings.auto_seed_personas``materialize_seed_personas()`로 시드 P1~P3과 저장소 `data/personas/P4~P7.json``app.persona_card`에 upsert.
2. `settings.auto_seed_personas``materialize_seed_personas()`로 시스템 페르소나 P1~P3과 저장소 `data/personas/P4~P7.json``app.persona_card`에 누락분만 물리화한다. 기존 DB 저작본/보관본은 덮어쓰거나 되살리지 않는다.
3. `engine_client.startup()` / `voice_service.startup()`로 httpx 클라이언트 준비.
4. **DB 초기화 실패 시** `environment == "dev"`이면 예외를 삼키고 경고만 남긴 채 degraded 기동한다
(그 외 환경은 raise). `app/main.py:47-53`.
@ -150,7 +150,8 @@ assistant/user 히스토리(L6) → `user(이번 마스킹 발화, L5)`. 게이
- 자살·자해의 구체적 '방법/수단'은 절대 발화하지 않는다.
- `_format_openness_directive()``effective_openness` 수치를 연기 강도 지시문으로 환산한다(수치 자체는 비노출).
시드 페르소나(개발 부트스트랩): `SEED_PERSONAS = {P1, P2, P3}`와 저장소 `data/personas/P4~P7.json``persona_repository.built_in_personas()`.
시스템 페르소나 부트스트랩: `SEED_PERSONAS = {P1, P2, P3}`와 저장소 `data/personas/P4~P7.json``persona_repository.built_in_personas()`.
초기 DB 카탈로그를 채우기 위한 원천일 뿐, 승인 이후 편집/보관 결정은 `app.persona_card`가 SSOT다.
| 코드 | 인물 | 난이도 | 이론타깃 | 특징 |
|---|---|---|---|---|
@ -222,6 +223,27 @@ RBAC×AIView로 차단된다. 이 모듈은 평가 신호만 산출한다.
⚠️ 의존 방향: evaluator는 `taxonomy/engine_client/orchestrator/state_machine/persona`를 *읽기 전용*으로만
의존하고, orchestrator는 evaluator를 import 하지 않는다(단방향).
### 2.6.1 라이브 코칭 AI — `app/services/live_coach.py`
라이브 코칭은 내담자 생성 루프에 끼워 넣지 않는 별도 슈퍼비전 경로다. `POST /sessions/{id}/stream`
또는 음성 턴이 끝난 뒤, 프론트가 `POST /sessions/{id}/live-coach`를 호출해 "다음 한 문장" 중심의
짧은 코칭을 받는다. 엔진/RAG 장애는 상담 흐름을 막지 않고 워크북 기반 규칙 코칭으로 degrade한다.
- 입력은 `guardrail.mask_pii()` 후 evaluator 역할로 전송한다. 페르소나 CCD·상태 수치·정답키는 학습자에게
노출하지 않는다.
- 기본 근거는 `data/kb/live_coaching_workbook_0615.json``data/kb/live_coaching_sources/*.json`
허가된 0615 워크북·DSM·공식 지침 요약 청크다. 추가 RAG는 evaluator view로
`theory/technique/supervisor_pattern/microskill/taxonomy` 근거만 회수한다.
- 관리자 `POST /kb/live-coach/source-packs/sync`는 같은 source pack을 `kb.source``kb.chunk`
content_hash 기반으로 증분 색인한다. 기본 visibility는 evaluator 전용이고, C/D·diagnostic 자료는
sensitivity 2로 고정한다.
- 출력은 `LiveCoachSuggestion` 구조화 JSON(`tone/focus/title/message/next_utterance/rationale/sources`).
UI는 코칭 아바타 말풍선, 근거 모달, 발화별 코칭 이력 오버레이로 표시한다.
- 전달된 코칭은 `app.live_coach_events`에 저장된다. 원문 축어록을 중복 저장하지 않고 PII 마스킹된 짧은
learner/client excerpt와 코칭 payload만 저장한다. DB 미가용 dev에서는 런타임 캐시로 폴백한다.
- DSM/공식 지침/논문은 사용 허가된 source pack으로 투입할 수 있다. 다만 코칭 프롬프트와 UI에는
장문 원문이나 공식 문항을 재현하지 않고, chunk summary + version/citation + 출처 식별자로 노출한다.
### 2.7 회기 메모리 — `app/services/memory.py`
회기 라이프사이클 메모리(4계층 매핑: ① working / ② episodic / ③ summary / ④ semantic).
@ -240,9 +262,13 @@ RBAC×AIView로 차단된다. 이 모듈은 평가 신호만 산출한다.
OpenAI STT/TTS 어댑터(순수 변환 + voice preset 매핑). 상담 로직은 라우트가 조립한다.
- STT: `/audio/transcriptions` (gpt-4o-transcribe → 404 시 whisper-1 폴백), 언어 힌트 `ko`.
- TTS: `/audio/speech` (gpt-4o-mini-tts → tts-1 폴백). 페르소나 preset → OpenAI voice 매핑
(`PRESET_TO_OPENAI_VOICE`, P1=coral / P2=ash / P3=shimmer). 스트림 청크마다 립싱크 RMS 힌트
(`estimate_chunk_rms`, PCM 디코딩 없이 바이트 에너지 근사) 동봉.
- TTS: `/audio/speech` (gpt-4o-mini-tts → tts-1 폴백). 음성 선택 우선순위는 명시 query preset →
`app.persona_voice_map`의 OpenAI row(`base_params.preset/openai_voice/rate/instructions`) →
페르소나 code 기본 preset(`PRESET_TO_OPENAI_VOICE`, P1=coral / P2=ash / P3=shimmer)이다.
OpenAI가 아닌 provider row는 현재 live OpenAI TTS로 보내지 않고 기존 fallback을 사용한다.
dev 런타임 스키마 보강은 기존 DB의 `app.persona_voice_map` 누락도 복구해 seed materializer와
`/voice/ws` 바인딩이 같은 테이블을 사용하게 한다.
스트림 청크마다 립싱크 RMS 힌트(`estimate_chunk_rms`, PCM 디코딩 없이 바이트 에너지 근사)를 동봉한다.
- 키 없으면 명확히 degraded(`is_available()=False`, `VoiceUnavailable`). 라우트가 503/WS close로 변환.
`/voice/ws` WebSocket 캐스케이드(`routes/voice.py`):
@ -272,19 +298,58 @@ server: ready → state(listening) → state(thinking) → transcript → reply
순차 append → 상태 갱신. `EngineError`는 503으로 변환.
- `POST /sessions/{id}/stream` — SSE 경로(`run_turn_stream`). token/done/ping/error를 흘리고,
done 시점에 학습자 발화 + 누적 내담자 응답을 영속화. `sse_heartbeat_seconds`마다 ping(Cloudflare 타임아웃 회피).
- `POST /sessions/{id}/live-coach` — 방금 완료된 상담자 발화를 워크북 요약/RAG/evaluator fast-loop 신호와 대조해
라이브 코칭 카드 1개를 반환하고 `app.live_coach_events`에 저장한다. 저장 payload는 마스킹 excerpt와 코칭 구조화 JSON이다.
- `GET /sessions/{id}/live-coach` — 현재 회기에서 학습자에게 실제 전달된 코칭 이력을 시간순으로 반환한다.
세션 화면은 이 응답을 `turn_seq`별로 묶어 학습자 발화 우측 코칭 마커와 채팅 위 스크롤 오버레이에 표시한다.
- `POST /kb/live-coach/source-packs/sync` — 관리자 전용. 로컬 라이브 코칭 source pack을 `kb.source` upsert 후
`kb.document/kb.chunk`로 색인한다. 임베딩 모델이 없으면 BM25-only degraded 색인으로 진행한다.
- `POST /sessions/{id}/end``memory.make_carry_over(...)` → 세션 종료 + carry 준비 →
`_schedule_session_evaluation(sess)`로 deep-loop 평가를 비동기 태스크로 발사.
- `GET /sessions/dashboard` — 학습자 본인 세션만 `include_turn_evaluation=true`로 집계해
`overview/growth/persona_progress/achievements/recent_feedback`를 반환한다. `overview.archived_sessions`
학습자별 보관 상태(`app.session_archive_state`) 기준 카운트이며, 보관된 종료 회기는 리뷰 대기 행동 큐에서
제외한다. 성취는 공식 등급/수료가 아니라 실제 연습 milestone만 표시한다.
- `GET /sessions/{id}/review` — 저장된 축어록 + 평가 AI 산출물로 학습자-안전 리뷰 구성
(`_rubric_from_evaluation`, `_ai_review_points` 등). 리뷰 조회 시 발화별 fast-loop 평가는
`app.feedback_scores`/라벨 조인 테이블에서 `TurnRecord.evaluation` 형태로 hydrate한다.
평가 미완이면 `degraded/reviewReady=false`로 표기. 학습자가 저장한 사례개념화 워크시트가 있으면
자동 초안보다 `saved_by_learner` 저장본을 우선 반환한다.
자동 초안보다 `saved_by_learner` 저장본을 우선 반환한다. 교수자/관리자는 담당 범위 회기를 읽기
전용으로 검토할 수 있지만, 학습자 워크시트 저장 권한은 learner 전용으로 유지한다.
`ReviewNote.body`는 제한 markdown(`code`, `strong`, blockquote, list)으로 렌더링하고,
`effective_openness` 같은 내부 수치는 `effective openness(유효 개방도)`로 학술 용어화한다.
상담자 질문/발화 근거는 `ReviewNote.quote`로 분리해 리뷰 카드 안에서 인용 블록으로 표시한다.
- `POST /sessions/{id}/share` — 학습자가 종료된 본인 회기 리뷰를 URL로 공유하기 위해 공개 토큰을 생성한다.
서버는 `app.session_share_link`에 토큰 해시와 sanitized preview payload만 저장하고, 원문 축어록·학습자
식별자는 payload에 넣지 않는다. 토큰은 생성 응답에서만 반환되며 새 생성은 기존 토큰을 교체한다.
- `DELETE /sessions/{id}/share` — 해당 회기의 공개 공유 토큰을 폐기한다.
- `POST /sessions/{id}/archive` / `POST /sessions/{id}/restore` — 학습자 본인의 종료 회기를 보관/복원한다.
진행 중 회기는 409로 거부한다. 보관은 `app.session_archive_state`만 upsert/delete하는 보기 상태이며,
`app.sessions`, `app.turns`, 리뷰, 공유 링크, 연구/감사 증거는 삭제하지 않는다.
- `PUT /sessions/{id}/review/worksheet` — 학습자가 수정한 사례개념화 워크시트를 저장한다.
저장본은 `app.case_worksheet`에 session 단위로 upsert되며, owner learner 또는 teacher/admin RLS 범위에서만 읽힌다.
발화 가시성: 학습자에게는 `visible_to``counselor`가 포함된 턴만 보여준다
(`_learner_visible_turns``_LEARNER_VISIBLE_AI_ROLE="counselor"`). 평가 전용 데이터는 노출되지 않는다.
### 2.9.1 교수자 회기 검토 상태 — `app/routes/teacher.py`
- `GET /teacher/dashboard` — 담당 학습자 성장, 안전 알림, 종료 회기 검토 큐를 반환한다. 회기 요약에는
`review_status`, `review_note`, `reviewed_at`를 포함해 교수자가 이미 검토한 회기를 구분한다.
- `PUT /teacher/sessions/{session_id}/review-status` — 교수자/관리자가 회기 검토 메모와 상태를 저장한다.
저장 대상은 `app.session_review_status`이며, 검토 완료된 회기는 pending queue에서 제외된다.
- `/teach/session/:sessionId/review` 화면은 같은 `GET /sessions/{id}/review` 자료를 교수자 읽기 전용으로 표시하고,
검토 메모 저장은 위 teacher endpoint로 분리한다.
### 2.9.2 공개 공유·검색 메타 — `app/routes/share.py`
- `GET /share/session/{token}` — 인증 없이 접근 가능한 unfurl HTML. Open Graph/Twitter Card/JSON-LD를 서버에서
직접 내려 URL만 전달해도 카카오톡·Slack·메일·AI 브라우저가 제목/요약/썸네일을 읽을 수 있게 한다.
- `GET /share/session/{token}/summary` — 동일 sanitized payload의 JSON 응답. 회기 원문, 턴별 축어록, 학습자
식별 정보는 포함하지 않는다.
- 공유 HTML과 API 도메인 `/robots.txt``noindex`/`Disallow: /share/` 정책을 둔다. 공유 URL은 검색 색인용
공개 문서가 아니라 교수자 전달용 미리보기 카드다.
### 2.10 엔진 클라이언트 — `app/engine_client.py`
게이트웨이 HTTP 클라이언트(호출부만). 계약:
@ -318,10 +383,18 @@ GET {ENGINE_URL}/ready|/health — readiness/liveness
DB `app.persona_card`가 승인 페르소나의 SoR. 이 모듈이 in-proc `PersonaCard`와 DB 행을 잇는 경계다.
- `load_file_personas()` / `built_in_personas()` — in-code P1~P3과 저장소 `data/personas/P4~P7.json`을 deterministic catalog로 합친다.
- `materialize_seed_personas()` — built-in P1~P7을 `status='approved'`로 upsert(admin 롤).
- `materialize_seed_personas()` — built-in P1~P7을 초기 승인 카탈로그로 누락분만 insert(admin 롤). `ON CONFLICT DO NOTHING`이므로 교수 편집본을 덮어쓰거나 `archived` 보관본을 재승인하지 않는다.
- `create_persona_revision_from_existing()` — 공개 승인본을 같은 `persona_id`의 다음 `version` draft/review로 복제한다. 이미 열린 draft/review가 있으면 새 버전을 만들지 않고 기존 초안을 돌려준다.
- `archive_persona_family()` — 교수/관리자 UI의 삭제 동작. 세션 FK 보존을 위해 hard delete 대신 같은 `code` family의 비보관 버전을 모두 `status='archived'`로 전환한다.
- `get_approved_persona(code)` / `list_approved_personas()` — 승인된 최신 버전 조회(AI 컨텍스트).
- `get_catalog_persona(code)` — 승인 카드 조회, 실패 시 `settings.allow_seed_persona_fallback`이면
`seed_fallback_persona`(degraded=True)로 폴백.
- 페르소나 스튜디오: `/teach/personas`가 teacher/admin 전용 저작 작업면이다. 교수 콘솔은 진입점/triage만 맡고,
실제 저작은 개요·임상·저항·회기·말투·안전·프롬프트 탭으로 분리한다.
- RAG 첨부 SSOT: `POST /personas/sources`는 첨부/붙여넣기 자료를 `mask_pii()`
`kb.source/document/chunk`에 evaluator 전용 근거(`visible_to=['evaluator']`, `sensitivity=2`)로 등록한다.
`POST /personas/drafts/generate`는 raw text가 아니라 `source_id` 기반 RAG 검색 결과를 생성 근거로 사용하고,
draft `source_provenance`에 source id와 chunk id를 남긴다.
- 교수 검수: `list_persona_review_queue(role)` / `update_persona_review_status(action=approve|reject)`
— 승인/반려 시 `audit.audit_log`에 감사 기록.
@ -367,12 +440,20 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
| 경로 | 화면 | 역할 가드 |
|---|---|---|
| `/login` | Login (dev-login 경로 포함) | 공개 |
| `/learn` | LearnerHome | learner |
| `/pending` | PendingApproval(승인 대기/보류 안내) | 인증됨, approved 전용 제한 화면 |
| `/learn` | LearnerHome(대시보드: 학습 요약, 최근 회기 리캡, AI 코치) | learner |
| `/learn/practice` | LearnerHome(연습 대상 선택·새 회기 시작) | learner |
| `/learn/history` | LearnerHome(회기 기록·보관/복원·리뷰 진입) | learner |
| `/learn/session/:sessionId` | Session(상담 화면) | learner |
| `/learn/session/:sessionId/review` | SessionReview(회기 리뷰) | learner |
| `/learn/avatar-expressions` | AvatarExpressionLab | learner |
| `/teach` | Professor(교수자 대시보드) | teacher |
| `/admin` | Admin | admin |
| `/teach/personas` | PersonaStudio(페르소나 저작·검수) | teacher/admin |
| `/teach/session/:sessionId/review` | SessionReview(교수자 읽기 전용 회기 검토) | teacher |
| `/admin` | Admin(운영 홈) | admin |
| `/admin/users` | Admin(사용자 관리) | admin |
| `/admin/access` | Admin(접근 권한) | admin |
| `/admin/tickets` | Admin(운영 티켓 처리 큐: 접수, 우선순위, 상태 변경) | admin |
| `/settings` | Settings | 3역할 공통 |
- `RequireAuth``lib/auth`의 AuthContext로 가드하고, 권한 불일치 시 역할 홈(`roleHomePath`)으로 보낸다.
@ -380,16 +461,27 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
- `apiFetch`/`api`(get/post/put/del) — 표준 JSON, 에러는 `ApiError`로 정규화, 항상 `credentials:"include"`.
- SSE: `openSessionStream(sessionId, text, handlers)``fetch` 스트림을 직접 라인 파싱해
`token | done | safety | ping | error` 이벤트를 콜백으로 전달.
- 도메인 헬퍼: `sessionApi`(list/get/start/turn/end/review/stream), `personaApi`, `personaReviewApi`,
- 도메인 헬퍼: `sessionApi`(dashboard/list/get/start/turn/liveCoach/liveCoachHistory/end/review/stream/archive/restore), `personaApi`, `personaReviewApi`,
`adminApi`, `adminEngineApi`, `teacherApi`, `userApi`. 주요 응답 타입은 FastAPI OpenAPI에서 생성한
`src/lib/api.gen.ts``ApiSchema<...>` alias를 사용하고, generated optional 배열은 화면 렌더링 계층에서
빈 배열 fallback으로 흡수한다. 세션 시작/상세/턴 응답의 stage는 backend `StageLabel`
enum(`라포|탐색|개입|정리`)에서 생성한 union을 `SessionStage`로 사용한다.
- `sessionApi.createShare`/`revokeShare` — 학습자 리뷰 화면에서 공개 공유 URL 생성/폐기를 호출한다.
`SessionReview`은 종료된 회기에서만 "공유 URL 복사" 버튼을 노출한다.
- dev 환경: `vite.config``/api``http://127.0.0.1:8000` 프록시(`/api` 프리픽스 제거).
배포 호스트(`vignette.chanpaca.net`, `*.pages.dev`)에서는 `api-vignette.chanpaca.net`을 직접 가리킨다.
세션 화면 흐름(요약): 학습자 발화 입력 → `sessionApi.stream(id, text)`(SSE) 또는 `turn`(동기) →
토큰 누적 표시 → 회기 종료(`end`) → `/review`에서 `sessionApi.review(id)`로 리뷰 표시.
토큰 누적 표시 → 코칭 모드면 `sessionApi.liveCoach(...)` + `liveCoachHistory(...)`로 아바타 말풍선/발화별
코칭 마커 갱신 → 회기 종료(`end`) → `/review`에서 `sessionApi.review(id)`로 리뷰 표시.
정적 SEO/GEO 자산:
- `apps/web/index.html` — 기본 description/canonical/Open Graph/Twitter Card/JSON-LD(WebApplication).
- `apps/web/public/robots.txt` — 인증 내부 경로(`/pending`, `/learn`, `/teach`, `/admin`, `/settings`, `/dev`) 색인 차단.
AI 검색용 `OAI-SearchBot`, `ChatGPT-User`에도 같은 경계를 명시한다.
- `apps/web/public/sitemap.xml` — 공개 entry point만 포함한다.
- `apps/web/public/llms.txt` — AI 검색/요약용 공개 설명, 공유 URL의 no-transcript 정책, 비공개 경로 경계를 명시한다.
---
@ -401,19 +493,37 @@ DB는 PostgreSQL 16 + pgvector(단일 SoR). 초기화 SQL은 `infra/db/init/`에
### 5.1 app 스키마 (`infra/db/init/02_schema.sql`)
- **인적 주체** `app.app_user` — RBAC 앵커(role: learner/instructor/admin, cohort, consent_at).
- **인적 주체** `app.app_user` — RBAC 앵커(role: learner/teacher/admin, cohort, consent_at)와
계정 승인 상태(`account_status`: pending/approved/suspended), 최초 온보딩 프로필(닉네임, 자기소개,
아바타 URL, 이름, 소속, 학과, 학년/직위, 연락처, 주소/수령지, 약관·개인정보 동의 버전).
- **페르소나** `app.persona_card` — 불변 버전드 카드(`UNIQUE(code, version)`, status draft→review→approved→archived,
ccd/dsm5_dimensional/affect_baseline는 JSONB). `app.persona_voice_map`(provider-agnostic 음성),
ccd/dsm5_dimensional/affect_baseline는 JSONB). `archived`는 카탈로그 제거용 tombstone이며 기존 세션은 `persona_id/persona_version` 핀으로 계속 해석한다.
`app.persona_voice_map`은 같은 `persona_id/version`에 묶인 provider-agnostic 음성 설정이다. live OpenAI TTS는
provider=`openai` row의 `voice_id``base_params``VoicePreset`으로 해석하고, seed materializer는 기본 OpenAI row를
충돌 없이 생성한다. 기존 dev DB에 테이블이 없으면 `ensure_runtime_tables()``persona_card` 기준 FK 구조로 보강한다.
`app.counselor_profile`(상담사 AI).
- **라벨 코드테이블**(taxonomy 3축) — `stage_def`, `technique_label_def`, `client_state_def`, `scale_def`.
- **세션** `app.sessions` — case_id/persona_id/persona_version 핀, stage_path. `case_id`
(persona_id, learner_id) 복합 인스턴스 식별.
- **회기 보관 상태** `app.session_archive_state``session_id`/`learner_id` 단위의 학습자 보기 상태.
`archived_at`/`updated_at`만 저장하고 restore 시 row를 삭제한다. RLS는 학습자 본인의 보관/복원과
teacher/admin 조회만 허용하며, 원 세션·발화·리뷰·공유 링크는 보존한다.
- **발화** `app.turns` — ② EPISODIC append-only. `text`(마스킹 원문)/`text_masked`(외부전송용),
`visible_to TEXT[]`(기본 `{client,counselor,evaluator}`), salience/is_pinned/contradicts,
음성 paralinguistic(audio_ref/silence_ms/speech_rate/barge_in). `turn_technique`/`turn_client_state`
다대다, `supervisor_comment`(rationale/critique + intent_deviation JSONB), `safety_events`.
- **사례개념화 제출물** `app.case_worksheet` — 회기 리뷰의 축어록 기반 자동 초안을 학습자가 편집해 저장한 JSONB.
`session_id` 단위 upsert이며, `GET /review`에서 자동 초안보다 우선된다.
- **교수자 검토 상태** `app.session_review_status` — 교수자/관리자의 회기 검토 상태, 메모, 검토 시각을
`session_id` 단위로 저장한다. teacher dashboard는 `review_status/review_note/reviewed_at`를 내려 보내며,
검토 완료된 회기는 pending queue에서 제외한다.
- **공개 공유 카드** `app.session_share_link``session_id` 단위 공개 토큰 해시와 sanitized preview payload.
RLS는 학습자 본인 생성/폐기와 teacher/admin 열람, public route의 AI 컨텍스트 조회만 허용한다. 원문 축어록을
저장하지 않는다.
- **운영 콘솔** `app.admin_health_event` — 관리자 `/admin/health` 조회 시점의 서비스별 헬스 샘플을
append-only로 남긴다. 이 값은 상시 모니터링 SLA가 아니라 운영 콘솔이 관측한 최근 샘플 이력이다.
`app.support_ticket`은 인증 사용자가 제출한 문제·불만·장애 티켓을 저장하고, 관리자는 `/admin/tickets`에서
우선순위와 처리 상태를 갱신한다. RLS는 티켓 제출자 본인 insert/select와 admin 전체 처리만 허용한다.
- **메모리 4계층**:
- ① WORKING `app.session_state` — 상태머신 수치 체크포인트(매 턴 UPSERT, openness/ideation CHECK 제약).
- ② EPISODIC 임베딩 `app.turn_embedding` — BGE-M3 dense `vector(1024)` + sparse, HNSW 인덱스.
@ -466,10 +576,35 @@ DB 레벨 이중강제(`04_audit_eval_rls.sql` §5, `app/db.py` `acquire()`):
- OAuth callback 실패는 authorization code, token, raw email을 남기지 않고 reason/status/도메인 수준 정보만
서버 로그에 남긴다. 프론트는 `token_exchange_failed`, `invalid_state`, provider error(`access_denied`/`provider_error`),
identity claim 실패를 구분하고 실패 reason code를 화면에 함께 표시한다.
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다. 코호트는
- 역할은 `AUTH_TEACHER_EMAILS`/`AUTH_ADMIN_EMAILS` email allowlist로 1차 판정한다.
`admin_access`는 기본 역할과 별도인 관리자 콘솔 진입 권한이며, 슈퍼 관리자만 `/admin/users`에서
부여·회수할 수 있다. `AUTH_SUPER_ADMIN_EMAILS`는 항상 관리자 콘솔 접근, 학습자·교수자 공간 접근,
approved 상태를 부여하는 신뢰 루트다(기본 `yunchan@twentyoz.kr`, `hoonjungkoo@hs.ac.kr`). 코호트는
`AUTH_EMAIL_COHORT_MAP``AUTH_DOMAIN_COHORT_MAP` 설정, SAML fixture의 `cohort` claim을 합쳐
`cohort_ids`로 세션에 저장한다. 관리 사용자 `app_user.external_id`는 provider subject 기반
(`google:`/`saml:`/`dev:`)으로 저장해 email 변경 리스크를 줄인다.
- `AUTH_ALLOWED_EMAIL_DOMAINS`는 기본 도메인 게이트다. 단, 슈퍼 관리자/관리자가 `/admin/users`
미리 만든 정확한 이메일은 도메인 밖이어도 Google/SAML/dev-login의 이메일 검증을 통과한다.
이 예외는 도메인 전체를 열지 않고, provider 로그인 시 기존 `email:<주소>` 관리 row를
`google:`/`saml:`/`dev:` external_id로 이어받아 역할·코호트·승인 상태를 보존한다.
- 신규 Google/SAML 사용자는 기본적으로 `account_status=pending`으로 생성된다
(`AUTH_NEW_USER_DEFAULT_STATUS`). `/auth/me`만 pending 상태 확인용으로 열어두고, 그 외 REST/WS 기능
경로는 `account_pending` 또는 인증 실패로 막는다. `AUTH_APPROVED_EMAILS`, 관리자 생성 사용자,
`AUTH_SUPER_ADMIN_EMAILS`, 로컬 dev-login(`external_id=dev:*`)은 자동 approved다.
- 프론트 전역 `PendingApprovalGate`는 pending/suspended 사용자를 `/pending`으로 돌리고, 관리자는
`/admin/users`의 가입 승인 탭에서 pending 계정을 approved 또는 suspended로 변경한다.
- Google/SAML/dev-login 성공 직후 `onboarding_completed_at`이 없거나 닉네임/자기소개가 비어 있으면
프론트 전역 `OnboardingGate``/onboarding` 외 모든 앱 URL(`/learn`, `/settings`, `/admin`,
`/dev/avatar-preview`, `/login` 포함)을 `/onboarding`으로 돌린다. 온보딩 중에는 공용 셸 메뉴를
렌더링하지 않고, 빈 상태 카드 대신 가입 직후 사용자 정보를 입력하는 단순 폼만 보여준다.
이 화면은 이메일을 다시 받지 않고 닉네임, 자기소개,
선택 아바타 이미지, 이름, 소속, 학과, 학년/직위, 연락처, 주소/수령지와 서비스 이용약관·개인정보
처리방침 초안 동의를 저장한다. 아바타 파일은 `POST /users/me/avatar`가 MIME/시그니처/3MB 제한을
확인한 뒤 `USER_UPLOAD_DIR/profile-avatars`에 저장하고 URL만 `app_user.avatar_url`에 보관한다.
학습자 `POST /sessions`와 dev 음성 persona 시작은 온보딩 완료 후에만 허용하며, 온보딩 저장 시
learner `consent_at`도 함께 세팅한다.
- 관리자/교수자 권한은 온보딩 화면에서 신청받지 않는다. 서버는 `AUTH_ADMIN_EMAILS` /
`AUTH_TEACHER_EMAILS` allowlist와 관리자 사용자 관리 경로로만 역할을 부여한다.
- 로컬/Tailnet dev는 dev-login을 사용한다. 공개 `OAUTH_REDIRECT_URI`가 로컬/Tailnet 세션이 아니라 public API
세션으로 돌아가는 혼선을 막기 위해 dev-origin의 Google 직접 시작은 `local_oauth_unavailable`로 차단한다.
@ -540,10 +675,15 @@ npm run build # vite
npm run e2e # Playwright (web+api+DB 스택 필요)
```
### 7.5 Docker compose (DB + API)
### 7.5 Docker compose (DB + API + Web + Proxy)
`infra/docker-compose.yml`(db: pgvector pg16 + api). Docker Desktop 필요. DB 컨테이너 기동 시
`infra/db/init/*.sql`이 순서대로 적용된다.
`infra/docker-compose.yml`(db: pgvector pg16 + api + web + proxy). Docker Desktop 필요. DB 컨테이너 기동 시
`infra/db/init/*.sql`이 순서대로 적용된다. API 이미지는 저장소 루트 컨텍스트에서 빌드해
`data/personas`를 포함하고, 사용자 업로드는 `apiuploads` 볼륨(`/app/uploads`)에 보관한다.
RAG 임베딩/리랭커 의존성은 기본 슬림 이미지에 넣지 않고 `INSTALL_RAG=true` build arg로만 설치한다.
라이브 코칭 source pack(`data/kb/live_coaching_*`)도 같은 이미지 입력이므로 clean clone/배포지에서 누락되면
`scripts/check-deploy-preflight.py`가 실패해야 정상이다. DB schema drift는 API startup에서 owner 권한을 키워
수습하지 않고, owner-run init/migration 후 app-role DSN으로 preflight를 통과시킨 뒤 API를 띄운다.
---

View file

@ -8,7 +8,7 @@ Vignette(AI 심리상담 시뮬레이션 훈련 플랫폼)를 로컬에서 띄
- `apps/api` — FastAPI 백엔드 (Python)
- `apps/api/engine_gateway` — AI 턴 생성용 엔진 게이트웨이(별도 프로세스, 포트 9099)
- `apps/web` — React 19 + Vite 프런트엔드
- `infra` — Docker Compose 스택(pgvector pg16 + api + web + rag + proxy)
- `infra` — Docker Compose 스택(pgvector pg16 + api + web + proxy)
- `scripts` — 운영/점검 스크립트
핵심 구성요소: 학습자(상담수련생)가 AI 내담자 페르소나와 회기를 진행하고, 종료 후 회기 리뷰 피드백을 받는다.
@ -87,7 +87,7 @@ python -m pip install -r apps\api\requirements.txt
```
`apps/api/requirements.txt``fastapi`, `uvicorn[standard]`, `asyncpg`, `pydantic`,
`pydantic-settings`, `python-multipart`, `httpx`, `sse-starlette`포함한다.
`pydantic-settings`, `python-multipart`, `httpx`, `sse-starlette`exact version으로 고정한다.
엔진 게이트웨이도 `fastapi`+`uvicorn`만 쓰므로 위 설치로 함께 충족된다.
(Presidio PII 마스킹은 선택 의존성이라 기본 제외 — 없으면 정규식 폴백으로 자동 degrade.)
@ -117,7 +117,10 @@ npm install
| `ENGINE_URL` | `http://127.0.0.1:9099` | 엔진 게이트웨이 베이스 URL |
| `ENGINE_MODE` | `claude_cli` | 엔진 provider 라우팅 |
| `AUTH_DEV_LOGIN_ENABLED` | `true` | dev-login 엔드포인트 활성화 |
| `AUTH_ALLOWED_EMAIL_DOMAINS` | `["hs.ac.kr","twentyoz.kr"]` | 로그인 허용 이메일 도메인(dev-login 포함 검증) |
| `AUTH_ALLOWED_EMAIL_DOMAINS` | `["hs.ac.kr","twentyoz.kr"]` | 기본 로그인 허용 이메일 도메인(dev-login 포함 검증). `/admin/users`에 미리 등록된 정확한 이메일은 도메인 밖이어도 예외로 로그인 가능 |
| `AUTH_SUPER_ADMIN_EMAILS` | `["yunchan@twentyoz.kr","hoonjungkoo@hs.ac.kr"]` | 학습자·교수자·관리자 공간 접근과 승인 상태를 부여할 슈퍼 관리자 이메일 |
| `AUTH_APPROVED_EMAILS` | `[]` | 신규 외부 로그인 시 pending 없이 바로 승인할 이메일 allowlist |
| `AUTH_NEW_USER_DEFAULT_STATUS` | `pending` | Google/SAML 신규 사용자의 기본 승인 상태. `dev:` 로그인은 로컬/E2E 편의를 위해 자동 승인 |
| `AUTH_EMAIL_COHORT_MAP` | `{}` | 특정 이메일을 cohort id로 매핑한다. 값은 comma-separated 문자열도 허용 |
| `AUTH_DOMAIN_COHORT_MAP` | `{}` | 이메일/Google hosted domain을 cohort id로 매핑한다. Google/SAML/dev-login 세션 `cohort_ids`에 반영 |
| `VIGNETTE_VOICE_POC_SAMPLE_TTS` | `false` | P1 무참조 샘플 음성을 `/voice/ws` TTS에 연결하는 개발 전용 플래그. 마이크/STT는 `OPENAI_API_KEY` 필요, 프로덕션 금지 |
@ -136,7 +139,7 @@ python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
### 2.3 seed 페르소나로 띄우기 (선택)
기본값은 seed 미적재(`AUTO_SEED_PERSONAS=false`)다. 내장 페르소나(P1~P3)를 메모리/DB에 올려서
기본값은 seed 미적재(`AUTO_SEED_PERSONAS=false`)다. 시스템 페르소나(P1~P3)와 저장소 페르소나(P4~P7)를 DB 카탈로그에 올려서
바로 회기를 만들고 싶으면 실행 전에 두 플래그를 켠다.
```powershell
@ -147,6 +150,7 @@ python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
```
`main.py` lifespan은 `settings.auto_seed_personas`가 켜져 있을 때만 `materialize_seed_personas()`를 호출한다.
이 호출은 `(code, version)` 누락분만 insert하며, 교수자가 수정한 DB 저작본이나 `archived` 보관본을 덮어쓰거나 재노출하지 않는다.
(또는 `apps/api/.env`에 두 키를 `true`로 적어도 된다.)
### 2.4 DB 없이 degraded 기동 (정상 동작)
@ -188,10 +192,19 @@ curl.exe http://127.0.0.1:8000/health
- 본문: `{ "email": ..., "role": "learner|teacher|admin", "display_name": ... }`
- `role` 기본값은 `learner`.
- 성공 시 `__Host-vignette_sid` 쿠키(및 dev 전용 `vignette_sid` 쿠키)를 세팅한다.
- dev-login 사용자는 로컬/E2E 흐름 유지를 위해 `account_status=approved`로 생성된다.
- Google/SAML 신규 사용자는 기본적으로 `account_status=pending`이며, 승인 전에는 `/pending` 화면만 볼 수 있다.
관리자 콘솔 `/admin/users`의 가입 승인 탭에서 `approved`로 바꾸면 역할 홈에 접근한다.
- 신규 사용자 또는 온보딩 미완료 사용자는 로그인 직후 `/onboarding`에서 닉네임, 자기소개,
선택 아바타 이미지, 이름, 소속, 학과, 학년/직위, 전화번호, 주소/수령지와 약관·개인정보
동의를 저장해야 역할 홈으로 이동한다. 학습자 회기 시작은 온보딩 완료와 동의가 모두 있어야 한다.
- 프로필 아바타 업로드는 API 작업 디렉터리 기준 `USER_UPLOAD_DIR`(기본 `uploads`) 아래
`profile-avatars/`에 저장되고, `/uploads/profile-avatars/...` URL로 서빙된다.
> 주의(이메일 도메인): dev-login도 `validate_google_identity_domain`을 거치므로 **이메일 도메인이
> `AUTH_ALLOWED_EMAIL_DOMAINS`에 있어야 한다**. 예: `learner@hs.ac.kr`. 다른 도메인이면
> `403 email domain is not allowed`.
> 주의(이메일 도메인): dev-login도 기본적으로 `validate_google_identity_domain`을 거치므로
> 이메일 도메인이 `AUTH_ALLOWED_EMAIL_DOMAINS`에 있어야 한다. 예: `learner@hs.ac.kr`.
> 단, 관리자가 `/admin/users`에 미리 등록한 정확한 이메일은 도메인 밖이어도 로그인할 수 있다.
> 미등록 외부 도메인은 계속 `403 email domain is not allowed`.
### 3.1 PowerShell(권장) — Invoke-RestMethod + 세션 쿠키
@ -233,6 +246,33 @@ curl.exe http://127.0.0.1:8000/auth/me -b cookies.txt
> 로컬/Tailnet 테스트는 dev-login을 사용한다. Google OAuth는 공개 도메인
> `https://vignette.chanpaca.net`에서만 실제 계정 흐름으로 검증한다.
### 3.4 라이브 코칭 source pack RAG 색인
`data/kb/live_coaching_workbook_0615.json``data/kb/live_coaching_sources/*.json`는 라이브 코칭의
기본 근거 source pack이다. API가 DB와 연결된 상태라면 관리자 dev-login 쿠키로 같은 자료를 RAG KB에도
증분 색인할 수 있다.
이 디렉터리는 Docker 이미지가 `COPY data ./data`로 포함하는 런타임 입력이다. 다른 머신에 심기 전에는
source pack 파일이 워크트리에 존재하는지 반드시 확인한다. commit하지 않는 배포 방식이면 같은 경로로 별도
provision해야 하며, 아래 preflight가 없으면 실패하게 둔다.
```powershell
python scripts\check-deploy-preflight.py --skip-db --env-file infra\.env.example --allow-placeholder-secrets
```
```powershell
$body = '{"email":"admin@hs.ac.kr","role":"admin","display_name":"로컬 관리자"}'
Invoke-RestMethod -Method Post -Uri http://127.0.0.1:8000/auth/dev-login `
-ContentType 'application/json' -Body $body -SessionVariable adminSession
Invoke-RestMethod -Method Post `
-Uri http://127.0.0.1:8000/kb/live-coach/source-packs/sync `
-WebSession $adminSession
```
응답의 `skipped_unchanged`가 0보다 크면 content_hash가 동일해 재색인을 건너뛴 source pack이 있다는 뜻이다.
임베딩 모델이 없으면 `degraded=true`로 BM25-only 색인이 되지만, 라이브 코칭 기본 로컬 근거는 계속 동작한다.
---
## 4. 웹(프런트엔드) 실행
@ -311,7 +351,7 @@ python scripts\probe-engine-gateway.py --json # 기계 판독용
## 6. (선택) Docker Compose 전체 스택
DB까지 포함한 통합 실행은 `infra/docker-compose.yml`(db pgvector pg16 + api + web + rag + proxy)을 쓴다.
DB까지 포함한 통합 실행은 `infra/docker-compose.yml`(db pgvector pg16 + api + web + proxy)을 쓴다.
Docker Desktop이 필요하고, `infra/.env`(템플릿: `infra/.env.example`)에 `POSTGRES_PASSWORD`,
`APP_DB_PASSWORD`, `SESSION_SECRET`, OAuth/OpenAI 키 등을 채워야 한다.
@ -321,8 +361,18 @@ docker compose up -d db # DB만
docker compose up -d # 전체
```
배포지에 심기 전에는 저장소 루트에서 preflight를 먼저 실행한다. DB 검증을 붙일 때는 owner 계정이 아니라
API가 쓸 app-role `DATABASE_URL`을 넘긴다. 실패하면 owner-run init/migration을 먼저 처리하고 API를 띄운다.
```powershell
python scripts\check-deploy-preflight.py --env-file infra\.env
python scripts\check-deploy-preflight.py --env-file infra\.env --database-url $env:DATABASE_URL --require-app-role
```
엔진 게이트웨이(9099)는 컴포즈 밖 호스트에서 돌리고, api 컨테이너는
`ENGINE_URL=http://host.docker.internal:9099`로 호출한다(compose 기본값).
RAG 임베딩/리랭커 의존성은 기본 이미지에 설치하지 않는다. 모델까지 포함한 API 이미지를 만들 때만
`infra/.env``INSTALL_RAG=true`를 설정한다.
---
@ -331,8 +381,8 @@ docker compose up -d # 전체
```powershell
# 백엔드 (apps/api)
cd apps\api
python -m pytest app\ -q # 현재 113 pass
python -m pytest engine_gateway\ -q # 7 pass
python -m pytest app/ -q # 현재 145 pass
python -m pytest engine_gateway\ -q # 현재 9 pass
# 웹 (apps/web)
cd apps\web
@ -363,7 +413,8 @@ npm run e2e # Playwright — web+api+DB 스택 필요
요청이 로컬 Origin/Host인지 확인. `apps/api`에서 uvicorn을 실행해 `.env`가 로드됐는지도 확인.
- **dev-login이 403 (`email domain is not allowed`)**: 이메일 도메인이
`AUTH_ALLOWED_EMAIL_DOMAINS`에 없음. `learner@hs.ac.kr` 같은 허용 도메인을 쓴다.
`AUTH_ALLOWED_EMAIL_DOMAINS`에 없고, `/admin/users`에 정확히 등록된 관리 사용자도 아니다.
`learner@hs.ac.kr` 같은 허용 도메인을 쓰거나 관리자가 해당 이메일을 먼저 등록한다.
- **`/auth/me`가 401**: 쿠키가 전달되지 않음. curl은 `-c`/`-b`로 쿠키를 저장·재사용하고,
PowerShell은 `-SessionVariable`/`-WebSession`을 쓴다. 브라우저는 프록시(5173) 경유로 호출해야