Stabilize runtime auth and E2E coverage

This commit is contained in:
Yun Chan 2026-06-26 14:47:00 +09:00
parent 6a3e3b541c
commit 188e899394
133 changed files with 55987 additions and 6775 deletions

View file

@ -36,6 +36,7 @@ docker compose up -d
|---|---|
| `POSTGRES_PASSWORD` | DB 비밀번호(필수) |
| `SESSION_SECRET` | 세션 서명키(필수, 랜덤) |
| `AUTH_ALLOWED_EMAIL_DOMAINS` | Google 로그인 허용 이메일 도메인(JSON 배열). 기본 `["hs.ac.kr","twentyoz.kr"]`. Google Console authorized domains가 아니라 서버에서 `email`/`hd` claim으로 강제 |
| `OPENAI_API_KEY` | 음성(STT/TTS) |
| `ANTHROPIC_API_KEY` | 엔진 폴백/클라우드 모드 |
| `ENGINE_MODE` / `ENGINE_URL` | 엔진 위치(위 표) |

View file

@ -1,63 +1,270 @@
# Vignette 핸드오프 노트
# Vignette Handoff
> 2026-06-26 02:0x KST · 세션 b00bd7f3 → 다음 세션. **새 세션은 이 파일부터 읽고 이어받는다.**
> Updated: 2026-06-26 14:43 KST. 새 세션은 이 문서와 `docs/DESIGN_CONCEPT.md`를 먼저 읽고 이어가면 된다.
## 프로젝트
한신대 SW중심대학 산학협력 — 생성형 AI 심리상담 시뮬레이션 훈련 플랫폼 **Vignette**.
- repo: `D:\workspace\vignette` · github.com/yunchan8804-blip/vignette (**master, f869d25**)
- 학습자가 가상 내담자(AI 페르소나)와 상담 연습 → 백그라운드 평가 AI가 기법 라벨링·피드백. 3역할(학습자/교수자/관리자).
## 현재 상태
## 라이브 URL
- **프론트(공개)**: https://vignette.chanpaca.net (= vignette-b1q.pages.dev, Cloudflare Pages)
- **API(터널)**: https://api-vignette.chanpaca.net (cloudflared named tunnel → localhost:8000)
- 라이브 경로: 로그인(학습자) → 학습자홈 → **연습 시작** → 상담세션 → **회기 시작** → 입력창 말걸기 → 서연(P1) 실응답
- Workspace: `D:\workspace\vignette`
- 로컬 웹 기본 포트: `http://localhost:5173`
- 로컬 API 기본 포트: `http://127.0.0.1:8000`
- 로컬 engine gateway 기본 포트: `http://127.0.0.1:9099`
- 공개 웹: `https://vignette.chanpaca.net`
- 공개 API: `https://api-vignette.chanpaca.net`
- Google OAuth 허용 이메일 도메인: `hs.ac.kr`, `twentyoz.kr`
- `frontenddesign` 스킬은 현재 세션의 사용 가능 스킬 목록에 없었다. 대신 `docs/DESIGN_CONCEPT.md`를 SSOT로 사용했다.
- 공개 API 터널은 현재 `C:\Users\encep\.cloudflared\vignette-config.yml`에서 `http://127.0.0.1:8001`을 본다.
- 공개용 API 프로세스는 `127.0.0.1:8001`에서 `ENVIRONMENT=prod`로 떠 있다. 로컬 개발 API `127.0.0.1:8000``dev`로 유지했다.
- `GET /personas`는 이제 인증 필요다. 로컬 `http://127.0.0.1:8000/personas`와 공개 `https://api-vignette.chanpaca.net/personas` 모두 비로그인 401 확인 완료.
- 로컬 웹이 `http://127.0.0.1:5175`처럼 다른 Vite 포트로 떠도 로그인은 `로컬 테스트 계정으로 계속` 버튼을 사용한다. Google OAuth 버튼은 로컬에서는 disabled로 둔다. 현재 OAuth callback이 공개 API로 돌아가기 때문에 로컬 Google OAuth는 로컬 세션에 붙을 수 없다.
- 중복 Vite/API/gateway/cloudflared 프로세스는 정리했다. 현재 확인용 프로세스는 `127.0.0.1:8000` dev API, `127.0.0.1:9099` engine gateway, `127.0.0.1:8001` prod public API, `cloudflared` tunnel 1개다. Playwright 전체 테스트는 자체 Vite web server를 `127.0.0.1:5173`에 띄워 검증했다.
- 공개 런타임 재기동 스크립트:
- start: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1`
- stop: `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\stop-public-runtime.ps1`
## ⚠️ 상주 인프라 (윤찬 PC, 세션 넘겨도 살아있음 — 안 끄면)
| 프로세스 | 포트 | 재시작 |
|---|---|---|
| 엔진 게이트웨이 (claude -p 상주풀) | 9099 | `cd apps/api && C:/Users/encep/AppData/Local/Programs/Python/Python311/python.exe -m uvicorn engine_gateway.gateway:app --host 127.0.0.1 --port 9099` |
| API 서버 (FastAPI) | 8000 | 아래 env로 `python -m uvicorn app.main:app --host 127.0.0.1 --port 8000` (WorkingDir apps/api) |
| cloudflared (vignette tunnel ca8c5be0) | — | `cloudflared tunnel --config C:\Users\encep\.cloudflared\vignette-config.yml run` |
## 이번 세션 완료
API env(필수): `ENGINE_URL=http://127.0.0.1:9099` `ENGINE_MODE=claude_cli` `ENVIRONMENT=dev` `CORS_ORIGINS=["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev","http://localhost:5173"]`
- health 확인: `curl https://api-vignette.chanpaca.net/health` → 200 / `curl -X POST .../sessions -d '{"persona_code":"P1","theory_mode":"humanistic"}'` → 201
- 남아 있던 E2E seed persona 결합을 제거했다.
- `session-persistence`, `teacher`, `voice` 스펙은 `/api/personas`에서 실제 DB persona를 가져와 사용한다.
- `voice-success`만 DB-offline fixture라 `ALLOW_SEED_PERSONA_FALLBACK=true``P1`을 의도적으로 유지하고 주석을 남겼다.
- 운영 설정 fail-closed를 강화했다.
- `ENVIRONMENT != dev`에서 dev login, seed fallback, 기본 session secret, OAuth 누락, localhost frontend/CORS를 설정 검증에서 거부한다.
- API lifespan은 DB 초기화 실패 시 dev에서만 degraded fallback을 허용하고, staging/prod에서는 시작 실패로 둔다.
- `SettingsConfigDict(populate_by_name=True)`를 추가해 테스트/코드에서 field name 기반 설정 생성이 가능하게 했다.
- `infra/docker-compose.yml`을 깨끗한 ASCII compose로 재작성했다.
- `ENVIRONMENT`, Google OAuth client id/secret, `OAUTH_REDIRECT_URI`, `CORS_ORIGINS`, `OPENAI_BASE_URL`을 API 컨테이너에 명시 주입한다.
- 기존 손상된 줄 때문에 `OPENAI_API_KEY`가 주석/중복으로 처리되던 문제를 제거했다.
- 기본 `FRONTEND_BASE_URL``https://vignette.chanpaca.net`로 수정했다.
- `infra/.env.example`에 운영 필수 값을 보강했다.
- `ENVIRONMENT=prod`, `OAUTH_GOOGLE_CLIENT_ID`, `OAUTH_GOOGLE_CLIENT_SECRET`
- 기본 CORS에서 localhost 제거. 로컬 개발은 주석대로 `ENVIRONMENT=dev`와 localhost CORS를 별도 추가.
- admin UI 안정화.
- health card의 긴 engine readiness JSON이 모바일 폭을 밀어내지 않도록 줄바꿈 처리.
- backend admin health는 동적인 raw JSON detail 대신 안정적인 `Engine readiness failed` 문구를 내려준다.
- 전역 engine config를 바꾸는 `db-persistence` E2E는 `@single-run`으로 분리했다.
- 사용자 생성 후 UI가 서버 목록 reload를 끝내기 전에 카드를 찾던 E2E 타이밍을 안정화했다. 생성한 이메일로 검색한 뒤 실제 서버 응답 카드만 검증한다.
- 사용자 관리 UI는 DB 사용자 저장소가 durable하지 않으면 생성/수정/비활성화를 막는다.
- AI 운영 라벨은 `Claude CLI 게이트웨이`, `Anthropic API`, `OpenAI 호환`, `Solar`처럼 실제 어댑터명으로 표시한다.
- 설정의 AI 운영 패널은 `/admin/health` 기반 응답 생성 상태를 함께 보여주되, health 지연이 설정 폼 로딩을 막지 않도록 분리했다.
- 비개발 환경에서 `admin_engine_config` singleton row가 없으면 runtime default로 내려가지 않고 fail-closed 한다.
- 비개발 DB 장애 상태는 `임시 기록`이 아니라 저장소 중단으로 표시한다.
- public Google OAuth smoke 문서를 보강했다.
- `E2E_PUBLIC_AUTH=1`에서는 로컬 Vite 서버를 띄우지 않고 공개 웹을 대상으로 한다.
- storageState는 인증 쿠키를 포함하므로 `node_modules/.tmp` 아래 민감 파일로 취급한다.
- API session TTL은 현재 8시간이다.
- 공개 API prod 전환을 완료했다.
- `cloudflared` ingress를 `127.0.0.1:8000`에서 `127.0.0.1:8001`로 변경했다.
- `https://api-vignette.chanpaca.net/health``environment:"prod"`, `db:true`, `engine:true`를 반환한다.
- public production-safe Playwright 게이트가 통과한다.
- 공개 런타임 start/stop 스크립트를 추가했다.
- `scripts/start-public-runtime.ps1`는 prod-safe env override로 API 8001을 띄우고 health를 검증한다.
- 기본 실행 시 `cloudflared`도 vignette config 기준으로 재시작한다.
- `scripts/stop-public-runtime.ps1`는 8001 API를 정리한다. `-StopCloudflared`를 붙이면 터널도 같이 정리한다.
- 공개 API 보안 경계를 보강했다.
- `apps/api/app/routes/personas.py``CurrentPrincipal`을 붙여 persona catalog를 authenticated-only로 전환했다.
- 로컬 E2E `auth.spec.ts`에 비로그인 `/api/personas => 401` 회귀를 추가했다.
- public readiness gate는 빈 API request context로 비로그인 `/personas => 401`을 확인한다. storageState가 붙은 request fixture를 쓰지 않도록 처리했다.
- 공개 웹 API base fallback을 보강했다.
- `apps/web/src/lib/api.ts``VITE_API_BASE`가 없고 host가 `vignette.chanpaca.net` 또는 `*.pages.dev`이면 `https://api-vignette.chanpaca.net`을 기본 API origin으로 사용한다.
- 이 변경으로 public web의 `/api/auth/config`가 SPA HTML로 떨어져 Google 버튼이 disabled 되는 문제를 피한다.
- 로컬 로그인 UX를 보강했다.
- `apps/web/src/pages/Login.tsx`는 로컬 origin에서 OAuth redirect URI가 공개 API이면 Google 버튼을 disabled 처리하고 `로컬 테스트 계정으로 로그인` 안내를 보여준다.
- `auth.spec.ts`에 로컬 테스트 계정 로그인 후 `/learn` redirect 회귀를 추가했다.
- 학습자 홈 기록 UX를 보강했다.
- `apps/web/src/pages/LearnerHome.tsx`는 세션이 0개인 사용자에게도 실제 서버 세션 0개를 기반으로 `기존 회기` 빈 상태와 0 카운트를 보여준다.
- 기록이 있을 때만 `has-session-records` 압축 레이아웃을 쓰도록 분리했다. 0회 사용자 모바일에서도 선택 내담자 정보, 새 회기 CTA, 기록 빈 상태가 문서 스크롤 없이 보인다.
- `apps/web/e2e/readiness.spec.ts`는 fake 12회/최근 8회 부재뿐 아니라 서버 기반 0회 빈 상태를 기대하도록 갱신했다.
- history의 반복 CTA는 `다시 시도` 대신 `다시 연습`으로 바꿔 기록 회고와 새 회기 시작 의미를 분리했다.
- 직접 세션 URL의 persona 우회 경로를 막았다.
- `LearnerHome`에서만 막던 `degraded` 또는 non-DB persona 차단을 `Session` 직접 진입 경로에도 적용했다.
- `/learn/session/:personaCode`로 직접 들어와도 DB 원본 persona가 아니면 `회기 시작` 버튼이 비활성화된다.
- desktop/mobile E2E에 degraded `seed_fallback` persona route mock 회귀를 추가했다.
- active session dense viewport를 보정했다.
- `apps/web/src/pages/session/session.css`에서 380px 이하/낮은 화면의 stage padding과 avatar 크기를 줄여 stage 내부 clipping을 없앴다.
- `session-layout.spec.ts` dense viewport desktop/mobile 회귀와 전체 Playwright에서 확인했다.
- active session 텍스트 턴 저장/표시 흐름을 실제 SSE 계약에 맞췄다.
- 프론트 `sessionApi.stream`은 실제 백엔드 계약인 `POST /sessions/{id}/stream`을 fetch stream으로 소비한다.
- `/stream` 요청이 거절되거나 SSE가 열린 뒤 error 이벤트로 끝나면 학습자 발화와 부분 내담자 응답을 transcript에 남기지 않고 입력값을 복원한다.
- 내담자 자막은 로컬 typewriter가 아니라 서버 token 이벤트로만 쌓는다.
- 백엔드 `/turn``/stream`은 엔진 성공 전 learner turn/state를 append하지 않도록 순서를 바꿨고, `app.test_session_turn_persistence` 회귀로 검증했다.
- 음성 회기 실패 턴 저장/표시도 같은 기준으로 맞췄다.
- voice route는 엔진 응답 생성 성공 후에만 learner/client turn을 저장한다.
- 프론트는 음성 transcript를 pending으로 표시하고, 엔진 실패 시 pending transcript를 제거한다.
- 음성 degraded reason의 내부 `seed_fallback`/runtime store 표현은 사용자용 한국어 상태 문구로 바꿨다.
- admin health의 dev DB fallback 문구를 `임시 기록`이 아니라 `비영구 런타임 기록`으로 정리했다.
- Live2D demo fallback을 제거했다.
- Mao/Haru 샘플 모델은 로컬 통합 확인용 자산으로만 남기고 모든 페르소나의 공용 fallback으로 쓰지 않는다.
- `AvatarPersona.live2dModelUrl`이 명시된 실제 페르소나 모델이 있을 때만 Live2D를 렌더링하며, 없거나 실패하면 SVG persona avatar를 유지한다.
## ✅ 완료 (대시보드 구현·인프라)
- 프론트 7화면(로그인/학습자홈/세션/리뷰/교수자/관리자/설정) 공개배포 + 라이트테마
- 백엔드 풀 라이브: 게이트웨이(/v1/generate·/v1/stream 어댑터) + API(orchestrator: 가드레일→상태머신→페르소나→엔진→평가) + **DB 없이 degraded(store 인메모리 폴백)**
- named tunnel + CORS + puppeteer 실화면 검증(서연 실응답)
- 커스텀도메인 vignette.chanpaca.net **SSL active**
- DB 마이그레이션 35테이블+pgvector (검증 후 Docker 내림. 운영 DB는 NAS PostgreSQL 설계)
- 합성 골든셋 62발화(P4-P7 비위기, `data/golden/`+`data/personas/`) + 평가AI few-shot 연결(`evaluator.py` GOLDEN_DIR)
## 검증 결과
## 🔴 다음 세션이 이어받을 작업 (윤찬 최신 지시 2건)
### 1. 학습자홈 목업 제거 (최우선) — `apps/web/src/pages/LearnerHome.tsx`
윤찬: "신규 사용자인데 **12회 연습/수련님/이미 있는 회기 목록**이 가짜로 차있으면 안 돼. 애당초 목업이 없어야."
- 빼야 할 mock: `MOCK_WEEK`(doneCount/reviewPending/weeklyGoal), `MOCK_SESSIONS`(회기 목록), `MOCK_GROWTH`(성장 추세), `MOCK_NEXT`(추천), 253줄 `"지금까지 12회 연습했어요"`, `MOCK_LEARNER_NAME_FALLBACK`("도윤"/"수련님")
- 목표: 신규 사용자 **빈 상태(empty state)** — 0회, 빈 회기, "아직 연습 기록이 없어요 → 첫 상담 연습을 시작해보세요" CTA 중심. 또는 API(`GET /sessions` 류)에서 실제 사용자 데이터 로드(없으면 빈).
- 주의: "연습 시작" 라우팅은 이미 고침(navigate(`/learn/session/:personaCode`)) — 빈 상태에서도 CTA는 살려야.
- Web typecheck:
- `cd apps/web; fnm env --use-on-cd | Out-String | Invoke-Expression; fnm use 22.22.3; npm run typecheck`
- Passed
- Backend unit/compile:
- `cd apps/api; C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m unittest app.test_runtime_policy app.test_session_turn_persistence engine_gateway.test_gateway_model`
- `20 tests OK`
- `python -m compileall app engine_gateway`
- Passed
- Web production build:
- `cd apps/web; fnm use 22.22.3; npm run build`
- Passed
- Docker compose config:
- `cd infra`
- With dummy required env vars, `docker compose --env-file .env.example config --quiet`
- Passed
- Focused Playwright after fixes:
- `npx playwright test e2e/admin.spec.ts e2e/db-persistence.spec.ts e2e/voice-success.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run`
- `10 passed`
- Admin manage-users focused rerun:
- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 PLAYWRIGHT_SKIP_WEB_SERVER=1 npx playwright test e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --grep "manage real server-known users"`
- `2 passed`
- Full Playwright:
- `cd apps/web; PLAYWRIGHT_HOST=127.0.0.1 npx playwright test`
- `72 passed` after public API/cloudflared was restored
- 참고: tunnel을 내린 직후 첫 전체 실행은 public login gate 2개만 `Cloudflare 1033`으로 실패하고 나머지 `70 passed`였다. `scripts\start-public-runtime.ps1`로 prod public API와 cloudflared를 복구한 뒤 전체 재실행에서 `72 passed`.
- Public auth project discovery:
- `E2E_PUBLIC_AUTH=1 npx playwright test --list --project=chromium-public-auth`
- `2 tests`: public production-safe config + real OAuth `/turn`
- Public production-safe gate:
- `E2E_PUBLIC_AUTH=1 npx playwright test e2e/public-auth-turn.spec.ts --project=chromium-public-auth --grep "production-safe"`
- `1 passed` on rerun
- Admin/settings focused E2E:
- `npx playwright test e2e/admin.spec.ts e2e/settings.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run`
- Latest focused reruns: `admin.spec.ts` `8 passed`, `settings.spec.ts` `9 passed`
- Auth/readiness focused E2E:
- `npx playwright test e2e/auth.spec.ts e2e/readiness.spec.ts --project=chromium-desktop --project=chromium-mobile`
- `14 passed`
- Persona auth boundary:
- local unauth `http://127.0.0.1:8000/personas` -> 401
- public unauth `https://api-vignette.chanpaca.net/personas` -> 401
- Public runtime script:
- `powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1`
- API 8001 health and public `https://api-vignette.chanpaca.net/health` both returned `environment:"prod"`, `db:true`, `engine:true`
- Public login screen:
- `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --grep "public login"`
- `1 passed`
- Local login on `127.0.0.1:5175`:
- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 PLAYWRIGHT_SKIP_WEB_SERVER=1 npx playwright test e2e/auth.spec.ts --project=chromium-desktop`
- `6 passed`
- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 PLAYWRIGHT_SKIP_WEB_SERVER=1 npx playwright test e2e/auth.spec.ts --project=chromium-mobile`
- `6 passed`
- focused local dev-login redirect: `1 passed`
- Learner home/history focused verification:
- `cd apps/web; npm run typecheck`
- Passed
- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 PLAYWRIGHT_SKIP_WEB_SERVER=1 npx playwright test e2e/learner.spec.ts e2e/readiness.spec.ts --project=chromium-desktop --project=chromium-mobile`
- `14 passed`
- Visual audit screenshots: `apps/web/node_modules/.tmp/ui-audit/learn-empty-desktop.png`, `apps/web/node_modules/.tmp/ui-audit/learn-empty-mobile-compact.png`
- Screenshot metrics confirmed desktop/mobile document overflow `x:0, y:0`, empty history visible, and real stats area present.
- Session dense viewport verification:
- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 PLAYWRIGHT_SKIP_WEB_SERVER=1 npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile`
- `8 passed`
- Latest voice/session focused E2E:
- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 PLAYWRIGHT_SKIP_WEB_SERVER=1 npx playwright test e2e/voice.spec.ts e2e/voice-success.spec.ts e2e/session-layout.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run`
- `15 passed`
- Latest learner/session/settings/admin focused E2E:
- `PLAYWRIGHT_BASE_URL=http://127.0.0.1:5175 PLAYWRIGHT_SKIP_WEB_SERVER=1 npx playwright test e2e/learner.spec.ts e2e/session-layout.spec.ts e2e/settings.spec.ts e2e/admin.spec.ts --project=chromium-desktop --project=chromium-mobile --project=chromium-single-run`
- `37 passed`
### 2. 음성 입력 — `services/voice.py` + `routes/voice.py`(WSS) + `Session.tsx` 마이크
윤찬: "크롬으로 하니까 내 음성이 안 들어감. 브라우저 어떻게 해야하는지 해야함."
- (a) 크롬 **마이크 권한** — HTTPS(vignette.chanpaca.net)는 OK, getUserMedia 권한 요청 UI 필요
- (b) **음성 파이프라인 실연동** — STT(OpenAI whisper) → orchestrator turn → TTS. WSS `/api/voice/ws`(Caddyfile passthrough 있음). Session.tsx 마이크 버튼이 현재 UI만 — WSS 연동 필요.
- ⚠️ **OpenAI 키 필요**: 현재 `.env`/API env의 `OPENAI_API_KEY`가 dummy. 윤찬에게 실키 받아야 STT/TTS 동작. 키 없으면 services/voice.py가 degraded(501/503).
## 아직 못 끝낸 것
## 외부 블로커 (자율 불가)
- **n=1 실 임상 축어록**: 한신대 교수 회신 필요. 요청 메일초안 = `Projects/영업/2026/한신대학교 산학협력 프로젝트/축어록_추가요청_메일초안.md` (윤찬 발송 대기). 합성 데이터로 개발 단계는 완화함.
- 공개 Google OAuth 실제 `/turn` proof는 아직 없다.
- 이유: `E2E_PUBLIC_STORAGE_STATE=apps/web/node_modules/.tmp/public-auth.json`가 아직 확보되지 않았다.
- 사용자가 Playwright codegen 브라우저에서 Google 로그인 후 storageState를 저장해야 한다.
- 2026-06-26 14:43 KST 기준 `apps/web/node_modules/.tmp/public-auth.json`은 없다. codegen 창/프로세스가 남아 있으면 로그인 완료 후 창을 닫아 저장을 완료하면 된다.
- 공개 API `environment:"dev"` 문제는 이번 세션에서 해결했다. cloudflared가 prod API인 `127.0.0.1:8001`을 보도록 변경했고 public gate로 확인했다.
- Google Console 설정은 브라우저 로그인 세션이 필요하다. 막히면 사용자가 로그인해 주기로 했다.
- worktree는 원래부터 매우 dirty이고, 이전 세션/사용자 변경이 많다. 관련 없는 변경을 되돌리지 말 것.
## 핵심 함정/패턴 (꼭 지킬 것)
- **빌드는 반드시 node22** — node23.4.0은 vite **segfault**(출력 0줄). PowerShell: `fnm env --use-on-cd | Out-String | Invoke-Expression; fnm use 22.22.3`
- 프론트 빌드: `$env:VITE_API_BASE="https://api-vignette.chanpaca.net"; npm run build`
- 배포: `wrangler pages deploy dist --project-name vignette --branch main --commit-dirty=true` (먼저 `dist/_redirects``/* /index.html 200`)
- **deployment 미리보기 URL은 CORS 불일치** → 검증은 production(vignette.chanpaca.net) URL로
- 라이브 검증 도구: `apps/web/_pptr_flow.cjs` (puppeteer-core + 시스템 Chrome, mock auth 주입 후 풀흐름 클릭) — `node _pptr_flow.cjs "https://vignette.chanpaca.net" <out.png>`
- dev 인증 우회: `ENVIRONMENT=dev`면 쿠키 없어도 통과(deps.py). 프론트 mock auth = localStorage `vignette.dev-auth`
- 한글 JSON은 **python httpx로** 보낼 것(curl -d 한글 인코딩 깨짐)
- Cloudflare 토큰: wrangler oauth(`%APPDATA%\xdg.config\.wrangler\config\default.toml`) / DNS 권한은 cloudflared `cert.pem`의 ARGO TOKEN apiToken
## 다음 세션 우선순위
## 윤찬 작업방식 (위반 주의)
- **완료보고 불신** — 실화면 인터랙션(puppeteer 클릭)까지 검증 후 보고. CORS/API 200만으론 "완료" 금지.
- **목업 싫어함** — 가짜 데이터로 화면 채우지 말 것. 빈 상태/실데이터.
- **디코로 답변 필수** — chat_id 1485472826118115338, inbound는 reply로.
- 위험은 한 번 알리고 주저말고 진행(확인 반복 금지). 단 Docker 같은 명시 가드는 존중.
1. Google OAuth Console을 확인한다.
- Authorized redirect URI: `https://api-vignette.chanpaca.net/auth/callback`
- Authorized JavaScript origin: `https://vignette.chanpaca.net`
- 앱/도메인 설정은 `hs.ac.kr`, `twentyoz.kr` 계정만 서버 claim 검증으로 허용한다. Google Console의 authorized domains와 이메일 도메인 제한은 다른 개념이다.
2. storageState를 캡처하고 public `/turn` smoke를 실행한다.
- `cd D:\workspace\vignette\apps\web`
- `npx playwright codegen https://vignette.chanpaca.net/login --save-storage=.\node_modules\.tmp\public-auth.json`
- `$env:E2E_PUBLIC_AUTH="1"`
- `$env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json"`
- `.\node_modules\.bin\playwright.cmd test e2e/public-auth-turn.spec.ts --project=chromium-public-auth`
3. UI/UX는 계속 `docs/DESIGN_CONCEPT.md`를 따른다.
- learner home은 빈 사용자에게 fake 통계/기록을 보여주지 않는다. 0회 상태는 실제 서버 기록 0개로 `기존 회기` 빈 상태를 보여주는 현재 구현을 유지한다.
- active learning session은 전체 화면, 문서 스크롤 없음이 원칙이다.
- 실제 session history는 `기존 회기`, `이어하기`, `기록/리뷰`, `다시 연습`, `새 회기 시작` 흐름으로 유지한다.
4. 다음 배포 전 `admin/engine-config`를 다시 확인한다.
- 의도된 현재값이 `claude_cli / http://127.0.0.1:9099 / gateway-default`인지 확인.
- E2E가 engine config를 건드리므로 배포 전 실제 운영값을 반드시 재확인한다.
## 로컬 실행 명령
Engine gateway:
```powershell
cd D:\workspace\vignette\apps\api
C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m uvicorn engine_gateway.gateway:app --host 127.0.0.1 --port 9099
```
API:
```powershell
cd D:\workspace\vignette\apps\api
$env:ENGINE_URL="http://127.0.0.1:9099"
$env:ENGINE_MODE="claude_cli"
$env:ENVIRONMENT="dev"
$env:AUTH_DEV_LOGIN_ENABLED="true"
C:\Users\encep\AppData\Local\Programs\Python\Python311\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000
```
Public API process:
```powershell
cd D:\workspace\vignette
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1
```
Web:
```powershell
cd D:\workspace\vignette\apps\web
fnm env --use-on-cd | Out-String | Invoke-Expression
fnm use 22.22.3
npm run dev -- --host localhost --port 5173
```
## 최근 주요 변경 파일
- `apps/web/e2e/support.ts`
- `apps/web/e2e/auth.spec.ts`
- `apps/web/e2e/learner.spec.ts`
- `apps/web/e2e/session-layout.spec.ts`
- `apps/web/e2e/session-review.spec.ts`
- `apps/web/e2e/session-persistence.spec.ts`
- `apps/web/e2e/teacher.spec.ts`
- `apps/web/e2e/voice.spec.ts`
- `apps/web/e2e/voice-success.spec.ts`
- `apps/web/e2e/public-auth-turn.spec.ts`
- `apps/web/e2e/db-persistence.spec.ts`
- `apps/web/e2e/README.md`
- `apps/web/playwright.config.ts`
- `apps/web/src/pages/Admin.tsx`
- `apps/web/src/pages/Login.tsx`
- `apps/web/src/pages/Session.tsx`
- `apps/web/src/pages/Settings.tsx`
- `apps/web/src/pages/session/session.css`
- `apps/web/src/components/avatar/ClientAvatar.tsx`
- `apps/web/src/components/avatar/persona.ts`
- `apps/web/public/live2d/README.md`
- `apps/api/app/config.py`
- `apps/api/app/main.py`
- `apps/api/app/routes/admin.py`
- `apps/api/app/routes/personas.py`
- `apps/api/app/routes/users.py`
- `apps/api/app/routes/voice.py`
- `apps/api/app/test_session_turn_persistence.py`
- `apps/api/app/test_runtime_policy.py`
- `infra/docker-compose.yml`
- `infra/.env.example`
- `docs/HANDOFF.md`
- `docs/dev_dashboard.html`
- `apps/web/src/lib/api.ts`

View file

@ -1,602 +1,378 @@
<!DOCTYPE html>
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vignette — 개발 오버뷰 (PM 보드)</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css">
<style>
/* =====================================================================
Vignette — 개발 오버뷰 대시보드 (내부 PM 보드, 앱 화면 아님)
토큰: DESIGN_CONCEPT.md §3 그대로 계승. accent = 세이지-틸(학습자 톤).
철칙: border-left 0 · 이모지 0(아이콘 inline SVG) · 카드덤프 0 ·
순흑/순백 금지 · 강조는 weight/tint/kicker/dot로만 · 한 화면 한 목적.
빨강은 리스크에만.
===================================================================== */
:root{
/* 중성 베이스 (따뜻한 종이 + 잉크 네이비-그린) */
--paper:#FBFAF8; --paper-2:#F4F2EE; --surface:#FFFFFF;
--neutral-100:#ECEFF1; --neutral-150:#E5E1DA; --neutral-200:#D5DBDE; --neutral-400:#98A4A9;
--ink:#1C2A2A; --ink-2:#5A6663; --ink-3:#93A09C; --hair:#E5E1DA;
/* 악센트: 세이지-틸 (프로젝트 톤) */
--accent:#3E7A6E; --accent-deep:#316257; --accent-tint:#EEF4F2; --accent-bright:#5F968B;
/* 보조 온기 (테라코타) — 화면당 절제 */
--clay:#B0735C; --clay-deep:#94604C; --clay-tint:#F6F0EC;
/* 의미색 */
--pos-text:#2C6E49; --pos-solid:#3B8C5E; --pos-tint:#E8F3EC;
--warn-text:#8A5A00; --warn-solid:#C28A2C; --warn-tint:#FAF1E0;
--crit-text:#A8352C; --crit-solid:#C4453A; --crit-tint:#FAEAE8;
--info-text:#2E5A7A; --info-solid:#3C7299; --info-tint:#EAF1F6;
--neutral-sig:#8895A2;
/* 별칭 */
--text-on-accent:#FBFAF8;
--focus-ring:rgba(62,122,110,.45);
/* 폰트 */
--font-sans:"Pretendard Variable",Pretendard,-apple-system,"Apple SD Gothic Neo","Noto Sans KR",system-ui,sans-serif;
--font-num:"Pretendard Variable","JetBrains Mono",ui-monospace,monospace;
/* spacing */
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px; --sp-7:48px; --sp-8:64px;
--radius:8px; --radius-sm:6px; --radius-lg:12px;
--maxw:1200px;
--shadow-sm:0 1px 2px rgba(28,42,42,.05);
--shadow-md:0 4px 14px rgba(28,42,42,.07);
--ease-out:cubic-bezier(.22,.61,.36,1);
}
*{margin:0;padding:0;box-sizing:border-box;}
html,body{height:100%;}
body{
font-family:var(--font-sans); background:var(--paper); color:var(--ink);
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
letter-spacing:-0.01em; line-height:1.6;
}
.tab-num{font-variant-numeric:tabular-nums; letter-spacing:-0.01em;}
/* ============ 공통 앱 셸: 톱바 ============ */
.topbar{
height:56px; background:var(--surface); border-bottom:1px solid var(--hair);
display:flex; align-items:center; gap:var(--sp-6); padding:0 var(--sp-6);
position:sticky; top:0; z-index:20;
}
.brand{display:flex; align-items:center; gap:10px;}
.brand .mark{display:flex; align-items:center; justify-content:center; width:26px; height:26px;}
.brand .mark svg{width:26px; height:26px;}
.brand .wm{font-size:17px; font-weight:700; letter-spacing:-0.02em; color:var(--ink);}
.brand .wm .v{color:var(--accent);}
/* 컨텍스트 라벨 (mono accent, 조용한 신호) */
.rolelabel{
font-family:var(--font-num); font-size:13px; font-weight:600;
letter-spacing:0.04em; color:var(--accent);
padding-left:var(--sp-4); margin-left:var(--sp-2);
border-left:1px solid var(--hair); /* 셸 구획 디바이더(강조선 아님) */
}
.topbar .spacer{flex:1;}
.repo{
font-family:var(--font-num); font-size:12.5px; color:var(--ink-3);
display:inline-flex; align-items:center; gap:7px;
}
.repo svg{width:14px; height:14px; stroke:var(--ink-3); fill:none;}
.repo .priv{
font-size:11px; font-weight:600; color:var(--ink-2); background:var(--paper-2);
padding:2px 8px; border-radius:100px; letter-spacing:0.02em;
}
.topbar .when{
display:flex; align-items:center; gap:8px; font-size:13px; color:var(--ink-2);
font-weight:500; margin-left:var(--sp-2);
}
.topbar .when .dot{width:7px; height:7px; border-radius:50%; background:var(--accent);}
.topbar .when .d{font-family:var(--font-num); font-variant-numeric:tabular-nums; color:var(--ink); font-weight:600;}
/* ============ 본문 래퍼 ============ */
.wrap{max-width:var(--maxw); margin:0 auto; padding:var(--sp-7) var(--sp-6) var(--sp-8);}
.kicker{
font-family:var(--font-num); font-size:12px; font-weight:600;
letter-spacing:0.08em; color:var(--accent);
display:flex; align-items:center; gap:8px;
}
.kicker .dot{width:6px; height:6px; border-radius:50%; background:var(--accent-bright);}
/* 페이지 헤드라인 (한 화면 한 메시지) */
.head{margin-bottom:var(--sp-7);}
.head .kicker{margin-bottom:var(--sp-3);}
.head h1{font-size:30px; font-weight:700; letter-spacing:-0.02em; color:var(--ink); line-height:1.3;}
.head h1 em{font-style:normal; color:var(--accent-deep);}
.head .sub{font-size:15px; color:var(--ink-3); margin-top:8px; max-width:680px;}
/* 헤드라인 옆 요약 스탯 라인 */
.headstats{
display:flex; align-items:center; flex-wrap:wrap; gap:var(--sp-5);
margin-top:var(--sp-5); padding-top:var(--sp-5); border-top:1px solid var(--hair);
}
.headstats .stat{display:inline-flex; align-items:baseline; gap:7px;}
.headstats .stat .num{font-size:20px; font-weight:700; color:var(--ink); font-variant-numeric:tabular-nums; letter-spacing:-0.02em;}
.headstats .stat .lab{font-size:13.5px; color:var(--ink-3);}
.headstats .vsep{width:1px; height:18px; background:var(--hair);}
/* 섹션 헤더 */
.sechead{margin-bottom:var(--sp-5);}
.sechead .kicker{margin-bottom:6px;}
.sechead .desc{font-size:13px; color:var(--ink-3);}
.block{margin-bottom:var(--sp-8);}
/* ============ 1. 로드맵 4단계 진행 막대 ============ */
.roadmap{
background:var(--surface); border:1px solid var(--hair); border-radius:var(--radius-lg);
box-shadow:var(--shadow-sm); padding:var(--sp-6) var(--sp-6) var(--sp-5);
}
.phase-grid{display:grid; grid-template-columns:repeat(4,1fr); gap:var(--sp-5);}
.phase .ph-top{display:flex; align-items:baseline; justify-content:space-between; margin-bottom:9px;}
.phase .ph-id{font-family:var(--font-num); font-size:12px; font-weight:700; letter-spacing:0.04em; color:var(--ink-3);}
.phase .ph-pct{font-family:var(--font-num); font-size:13px; font-weight:700; font-variant-numeric:tabular-nums; color:var(--ink-2);}
.phase.is-active .ph-pct{color:var(--accent-deep);}
.phase .ph-name{font-size:15px; font-weight:600; color:var(--ink); letter-spacing:-0.01em; line-height:1.4; margin-bottom:3px;}
.phase .ph-weeks{font-size:12px; color:var(--ink-3); font-variant-numeric:tabular-nums; margin-bottom:11px;}
.phase .track{height:8px; background:var(--paper-2); border-radius:4px; overflow:hidden;}
.phase .track .fill{height:100%; border-radius:4px; background:var(--neutral-150);}
.phase.is-active .track .fill{background:var(--accent);}
.phase .ph-state{
margin-top:11px; display:inline-flex; align-items:center; gap:7px;
font-size:12.5px; font-weight:500;
}
.phase .ph-state .d{width:7px; height:7px; border-radius:50%; background:var(--neutral-200);}
.phase .ph-state.active{color:var(--accent-deep); font-weight:600;}
.phase .ph-state.active .d{background:var(--accent);}
.phase .ph-state.wait{color:var(--ink-3);}
.phase .ph-note{font-size:11.5px; color:var(--ink-3); margin-top:7px; line-height:1.5;}
/* 단계 사이 연결 디바이더 (시각 흐름) */
.phase{position:relative;}
.phase:not(:first-child)::before{
content:""; position:absolute; left:calc(-1 * var(--sp-5) / 2 - 0.5px); top:7px; bottom:7px;
width:1px; background:var(--hair);
}
/* ============ 2단 본문 (좌: 완료/다음 · 우: 결정/리스크) ============ */
.cols{display:grid; grid-template-columns:1fr 1fr; gap:var(--sp-7); align-items:start;}
/* 완료 체크리스트 */
.panel{
background:var(--surface); border:1px solid var(--hair); border-radius:var(--radius-lg);
box-shadow:var(--shadow-sm); padding:var(--sp-6);
}
.panel + .panel{margin-top:var(--sp-6);}
.panel .ph{font-size:16px; font-weight:600; color:var(--ink); letter-spacing:-0.01em; margin-bottom:var(--sp-2);}
.panel .pd{font-size:12.5px; color:var(--ink-3); margin-bottom:var(--sp-5);}
.checks{display:flex; flex-direction:column;}
.chk-group + .chk-group{margin-top:var(--sp-5);}
.chk-glabel{
font-family:var(--font-num); font-size:11px; font-weight:600; letter-spacing:0.06em;
color:var(--ink-3); text-transform:uppercase; margin-bottom:10px;
}
.chk{display:flex; align-items:flex-start; gap:11px; padding:7px 0;}
.chk + .chk{border-top:1px solid var(--paper-2);}
.chk .mk{
flex:none; width:18px; height:18px; border-radius:50%; margin-top:1px;
background:var(--pos-tint); display:flex; align-items:center; justify-content:center;
}
.chk .mk svg{width:11px; height:11px; stroke:var(--pos-solid); fill:none; stroke-width:2.6;}
.chk .ct{font-size:13.5px; color:var(--ink); line-height:1.5;}
.chk .ct b{font-weight:600;}
.chk .ct .tag{
display:inline-block; margin-left:6px; font-family:var(--font-num); font-size:10.5px; font-weight:600;
letter-spacing:0.02em; color:var(--accent-deep); background:var(--accent-tint);
padding:1px 7px; border-radius:100px; vertical-align:middle;
}
.chk .ct .verified{color:var(--pos-text); background:var(--pos-tint);}
/* 진행중 / 다음 (도트 리스트) */
.nextlist{display:flex; flex-direction:column;}
.nx{display:flex; align-items:flex-start; gap:11px; padding:10px 0;}
.nx + .nx{border-top:1px solid var(--paper-2);}
.nx .nd{flex:none; width:8px; height:8px; border-radius:50%; margin-top:7px;}
.nx .nd.doing{background:var(--accent);}
.nx .nd.queued{background:var(--neutral-200);}
.nx .nt{font-size:13.5px; color:var(--ink); line-height:1.5;}
.nx .nt b{font-weight:600;}
.nx .nt .st{
display:inline-block; margin-left:6px; font-size:11px; font-weight:600;
padding:1px 8px; border-radius:100px; vertical-align:middle;
}
.nx .nt .st.doing{color:var(--accent-deep); background:var(--accent-tint);}
.nx .nt .st.blocked{color:var(--crit-text); background:var(--crit-tint);}
.nx .nt .st.queued{color:var(--ink-3); background:var(--paper-2);}
.nx .nt .why{display:block; font-size:12px; color:var(--ink-3); margin-top:3px; line-height:1.5;}
/* 확정 결정 */
.decisions{display:flex; flex-direction:column;}
.dc{display:grid; grid-template-columns:96px 1fr; align-items:baseline; gap:var(--sp-4); padding:11px 0;}
.dc + .dc{border-top:1px solid var(--paper-2);}
.dc .dk{font-size:12.5px; font-weight:600; color:var(--ink-3); letter-spacing:-0.01em;}
.dc .dv{font-size:13.5px; color:var(--ink); line-height:1.5;}
.dc .dv b{font-weight:600; color:var(--accent-deep);}
.dc.span{grid-template-columns:1fr;}
.dc.span .dk{
font-family:var(--font-num); font-size:11px; letter-spacing:0.06em; text-transform:uppercase;
color:var(--ink-3); margin-bottom:8px;
}
.dc .stack-chips{display:flex; flex-wrap:wrap; gap:7px; margin-top:3px;}
.dc .stack-chips .c{
font-family:var(--font-num); font-size:11.5px; font-weight:500; color:var(--ink-2);
background:var(--paper-2); padding:4px 10px; border-radius:100px;
}
.dc .stack-chips .c.key{color:var(--accent-deep); background:var(--accent-tint); font-weight:600;}
/* 리스크 · 블로커 (빨강은 여기에만) */
.risks{display:flex; flex-direction:column;}
.rk{display:flex; align-items:flex-start; gap:12px; padding:13px 0;}
.rk + .rk{border-top:1px solid var(--paper-2);}
.rk .sev{
flex:none; margin-top:3px; display:inline-flex; align-items:center; gap:7px;
font-family:var(--font-num); font-size:10.5px; font-weight:700; letter-spacing:0.04em;
width:64px;
}
.rk .sev .d{width:9px; height:9px; border-radius:50%;}
.rk .sev.high{color:var(--crit-text);} .rk .sev.high .d{background:var(--crit-solid);}
.rk .sev.mid{color:var(--warn-text);} .rk .sev.mid .d{background:var(--warn-solid);}
.rk .rbody .rt{font-size:13.5px; font-weight:600; color:var(--ink); line-height:1.45;}
.rk .rbody .rd{font-size:12.5px; color:var(--ink-2); line-height:1.55; margin-top:3px;}
.rk .rbody .rd .ext{
font-size:11px; font-weight:600; color:var(--clay-deep); background:var(--clay-tint);
padding:1px 7px; border-radius:100px; margin-left:4px;
}
/* ============ 3. 산출물 인벤토리 (테이블) ============ */
.inv-tbl{width:100%; border-collapse:collapse; font-size:14px;}
.inv-tbl thead th{
font-size:12px; font-weight:600; color:var(--ink-3); letter-spacing:0.02em;
text-align:left; padding:0 var(--sp-3) var(--sp-3); border-bottom:1px solid var(--neutral-200);
white-space:nowrap;
}
.inv-tbl tbody td{padding:var(--sp-4) var(--sp-3); border-bottom:1px solid var(--paper-2); vertical-align:top;}
.inv-tbl tbody tr:last-child td{border-bottom:none;}
.inv-tbl .area{font-weight:600; color:var(--ink); white-space:nowrap;}
.inv-tbl .area .kd{
display:inline-flex; align-items:center; gap:8px;
}
.inv-tbl .area .kd .d{width:7px; height:7px; border-radius:50%; background:var(--accent-bright);}
.inv-tbl .what{color:var(--ink-2); line-height:1.55;}
.inv-tbl .path{
font-family:var(--font-num); font-size:12px; color:var(--ink-3); line-height:1.7;
white-space:nowrap;
}
.inv-tbl .path code{
background:var(--paper-2); color:var(--ink-2); padding:2px 7px; border-radius:var(--radius-sm);
font-family:var(--font-num); display:inline-block; margin:1px 4px 1px 0;
}
.inv-tbl .stt{white-space:nowrap;}
.invtag{display:inline-flex; align-items:center; gap:7px; font-size:12.5px; font-weight:500;}
.invtag .d{width:7px; height:7px; border-radius:50%;}
.invtag.done{color:var(--pos-text);} .invtag.done .d{background:var(--pos-solid);}
.invtag.partial{color:var(--warn-text);} .invtag.partial .d{background:var(--warn-solid);}
/* 푸터 메타 */
.foot{
margin-top:var(--sp-6); display:flex; align-items:center; flex-wrap:wrap; gap:var(--sp-4);
font-size:12px; color:var(--ink-3); font-variant-numeric:tabular-nums;
}
.foot .fdot{width:5px; height:5px; border-radius:50%; background:var(--neutral-200);}
@media (prefers-reduced-motion: reduce){
*,*::before,*::after{animation-duration:.01ms!important; transition-duration:.01ms!important;}
}
@media (max-width:980px){
.phase-grid{grid-template-columns:repeat(2,1fr); gap:var(--sp-6);}
.phase:not(:first-child)::before{display:none;}
.cols{grid-template-columns:1fr; gap:var(--sp-6);}
.repo,.topbar .when{display:none;}
.inv-tbl .path{white-space:normal;}
}
@media (max-width:560px){
.phase-grid{grid-template-columns:1fr;}
}
</style>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vignette 개발 대시보드</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/gh/orioncactus/pretendard/dist/web/variable/pretendardvariable.min.css">
<style>
:root {
--paper:#FBFAF8;
--paper-2:#F4F2EE;
--surface:#FFFFFF;
--hair:#E5E1DA;
--ink:#1C2A2A;
--ink-2:#5A6663;
--ink-3:#93A09C;
--accent:#3E7A6E;
--accent-deep:#316257;
--accent-tint:#EEF4F2;
--clay:#B0735C;
--pos:#2C6E49;
--warn:#8A5A00;
--crit:#A8352C;
--pos-bg:#E8F3EC;
--warn-bg:#FAF1E0;
--crit-bg:#FAEAE8;
--info-bg:#EAF1F6;
--font:"Pretendard Variable", Pretendard, -apple-system, "Apple SD Gothic Neo", "Noto Sans KR", system-ui, sans-serif;
--num:"Pretendard Variable", "JetBrains Mono", ui-monospace, monospace;
--radius:8px;
}
* { box-sizing:border-box; }
body {
margin:0;
background:var(--paper);
color:var(--ink);
font-family:var(--font);
line-height:1.55;
letter-spacing:0;
}
header {
position:sticky;
top:0;
z-index:2;
display:flex;
align-items:center;
justify-content:space-between;
gap:24px;
min-height:58px;
padding:0 32px;
background:rgba(255,255,255,.92);
border-bottom:1px solid var(--hair);
backdrop-filter:blur(10px);
}
.brand { display:flex; align-items:center; gap:10px; font-weight:760; }
.mark { width:24px; height:24px; border:2px solid var(--accent); border-radius:50%; position:relative; }
.mark:after { content:""; width:6px; height:6px; border-radius:50%; background:var(--clay); position:absolute; left:7px; top:6px; }
.stamp { color:var(--ink-3); font:600 12px/1 var(--num); }
main { width:min(1180px, calc(100% - 40px)); margin:0 auto; padding:42px 0 64px; }
.hero { display:grid; grid-template-columns:minmax(0,1.2fr) minmax(280px,.8fr); gap:28px; align-items:start; margin-bottom:34px; }
.kicker { display:flex; align-items:center; gap:8px; color:var(--accent); font:700 12px/1.3 var(--num); letter-spacing:.08em; text-transform:uppercase; }
.kicker:before { content:""; width:6px; height:6px; border-radius:50%; background:var(--accent); }
h1 { margin:12px 0 0; font-size:30px; line-height:1.28; letter-spacing:0; }
h1 em { color:var(--accent-deep); font-style:normal; }
.lead { margin:12px 0 0; color:var(--ink-2); max-width:760px; font-size:15px; }
.summary {
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:1px;
overflow:hidden;
border:1px solid var(--hair);
border-radius:12px;
background:var(--hair);
}
.metric { min-width:0; padding:18px; background:var(--surface); }
.metric span { display:block; color:var(--ink-3); font-size:12px; font-weight:650; }
.metric b { display:block; margin-top:6px; color:var(--ink); font:760 28px/1 var(--num); }
section { margin-top:30px; }
.section-head { display:flex; align-items:end; justify-content:space-between; gap:18px; margin-bottom:14px; }
.section-head h2 { margin:0; font-size:20px; line-height:1.3; }
.section-head p { margin:4px 0 0; color:var(--ink-3); font-size:13px; }
.grid { display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:16px; }
.panel {
min-width:0;
background:var(--surface);
border:1px solid var(--hair);
border-radius:12px;
padding:20px;
box-shadow:0 1px 2px rgba(28,42,42,.05);
}
.panel h3 { margin:0 0 10px; font-size:16px; line-height:1.35; }
.panel p { margin:0; color:var(--ink-2); font-size:13px; }
.tag { display:inline-flex; align-items:center; gap:6px; margin-bottom:12px; padding:3px 9px; border-radius:999px; font-size:12px; font-weight:680; }
.tag:before { content:""; width:7px; height:7px; border-radius:50%; }
.done { color:var(--pos); background:var(--pos-bg); }
.done:before { background:var(--pos); }
.doing { color:var(--warn); background:var(--warn-bg); }
.doing:before { background:var(--warn); }
.risk { color:var(--crit); background:var(--crit-bg); }
.risk:before { background:var(--crit); }
.info { color:#2E5A7A; background:var(--info-bg); }
.info:before { background:#3C7299; }
.list { display:grid; gap:10px; margin:0; padding:0; list-style:none; }
.list li { display:grid; grid-template-columns:10px minmax(0,1fr); gap:10px; color:var(--ink-2); font-size:13px; }
.list li:before { content:""; width:6px; height:6px; margin-top:8px; border-radius:50%; background:var(--accent); }
code { font-family:var(--num); font-size:12px; background:var(--paper-2); padding:2px 6px; border-radius:6px; }
.wide { grid-column:1 / -1; }
.commands { display:grid; gap:10px; }
pre { margin:0; overflow:auto; padding:14px; border-radius:8px; background:#1E2724; color:#F6F0EC; font:12px/1.6 var(--num); }
table { width:100%; border-collapse:collapse; background:var(--surface); border:1px solid var(--hair); border-radius:12px; overflow:hidden; display:table; }
th,td { padding:12px 14px; border-bottom:1px solid var(--hair); text-align:left; vertical-align:top; font-size:13px; }
th { color:var(--ink-3); font-weight:700; background:var(--paper-2); }
tr:last-child td { border-bottom:0; }
@media (max-width:900px) {
header { padding:0 20px; }
main { width:min(100% - 28px, 1180px); padding-top:28px; }
.hero { grid-template-columns:1fr; }
.grid { grid-template-columns:1fr; }
}
@media (max-width:520px) {
.summary { grid-template-columns:1fr; }
h1 { font-size:24px; }
.stamp { display:none; }
}
</style>
</head>
<body>
<header>
<div class="brand"><span class="mark" aria-hidden="true"></span><span>Vignette 개발 대시보드</span></div>
<div class="stamp">2026-06-26 14:43 KST</div>
</header>
<!-- ============ 공통 앱 셸: 톱바 ============ -->
<header class="topbar">
<div class="brand">
<span class="mark">
<!-- 워드마크: 비네트(조리개) — inline SVG -->
<svg viewBox="0 0 26 26" fill="none" aria-hidden="true">
<circle cx="13" cy="13" r="11" stroke="#3E7A6E" stroke-width="2"/>
<path d="M8 14.5c1.4 1.7 3 2.5 5 2.5s3.6-.8 5-2.5" stroke="#3E7A6E" stroke-width="2" stroke-linecap="round"/>
<circle cx="13" cy="9" r="1.6" fill="#B0735C"/>
</svg>
</span>
<span class="wm"><span class="v">Vignette</span></span>
</div>
<span class="rolelabel">개발 오버뷰</span>
<span class="spacer"></span>
<span class="repo">
<svg viewBox="0 0 24 24" stroke-width="1.7"><path d="M9 19c-5 1.5-5-2.5-7-3m14 6v-3.87a3.37 3.37 0 0 0-.94-2.61c3.14-.35 6.44-1.54 6.44-7A5.44 5.44 0 0 0 20 4.77 5.07 5.07 0 0 0 19.91 1S18.73.65 16 2.48a13.38 13.38 0 0 0-7 0C6.27.65 5.09 1 5.09 1A5.07 5.07 0 0 0 5 4.77a5.44 5.44 0 0 0-1.5 3.78c0 5.42 3.3 6.61 6.44 7A3.37 3.37 0 0 0 9 18.13V22" stroke-linecap="round" stroke-linejoin="round"/></svg>
yunchan8804-blip/vignette
<span class="priv">private</span>
</span>
<span class="when"><span class="dot"></span>오늘 <span class="d">2026-06-25</span></span>
</header>
<!-- ============ 본문 ============ -->
<div class="wrap">
<!-- 페이지 헤드라인 -->
<div class="head">
<div class="kicker"><span class="dot"></span>한신대 산학협력 · 20주 · 매칭금 500만</div>
<h1>Phase 0 기반정렬 막바지, <em>엔진 실동작까지 검증</em>됐습니다.</h1>
<div class="sub">설계 4종·모노레포·디자인 7종·P0 코드 골격이 오늘 자로 완료됐고, 남은 블로커는 Docker 기동 후 DB 실마이그레이션과 외부 데이터 수급입니다.</div>
<div class="headstats">
<span class="stat"><span class="num">4</span><span class="lab">로드맵 단계</span></span>
<span class="vsep"></span>
<span class="stat"><span class="num">1</span><span class="lab">진행중 (P0)</span></span>
<span class="vsep"></span>
<span class="stat"><span class="num">6</span><span class="lab">확정 결정</span></span>
<span class="vsep"></span>
<span class="stat"><span class="num">3</span><span class="lab">열린 리스크</span></span>
</div>
</div>
<!-- 1. 로드맵 4단계 진행 막대 -->
<section class="block">
<div class="sechead">
<div class="kicker"><span class="dot"></span>로드맵 · 20주</div>
<div class="desc">단계별 진행률과 상태. 진행중인 단계만 색으로 강조, 대기 단계는 무채로 둡니다.</div>
</div>
<div class="roadmap">
<div class="phase-grid">
<!-- Phase 0 -->
<div class="phase is-active">
<div class="ph-top"><span class="ph-id">PHASE 0</span><span class="ph-pct">70%</span></div>
<div class="ph-name">기반정렬</div>
<div class="ph-weeks">1~2주차</div>
<div class="track"><div class="fill" style="width:70%"></div></div>
<div class="ph-state active"><span class="d"></span>진행중</div>
<div class="ph-note">스캐폴드·DB 스키마·엔진 PoC 거의 완료</div>
</div>
<!-- Phase 1 -->
<div class="phase">
<div class="ph-top"><span class="ph-id">PHASE 1</span><span class="ph-pct">0%</span></div>
<div class="ph-name">텍스트 상담 MVP</div>
<div class="ph-weeks">3~8주차</div>
<div class="track"><div class="fill" style="width:0%"></div></div>
<div class="ph-state wait"><span class="d"></span>대기</div>
<div class="ph-note">상담 루프 + 상태머신 + 실시간 피드백</div>
</div>
<!-- Phase 2 -->
<div class="phase">
<div class="ph-top"><span class="ph-id">PHASE 2</span><span class="ph-pct">0%</span></div>
<div class="ph-name">음성 + 3역할 + 페르소나</div>
<div class="ph-weeks">9~15주차</div>
<div class="track"><div class="fill" style="width:0%"></div></div>
<div class="ph-state wait"><span class="d"></span>대기</div>
<div class="ph-note">음성 캐스케이드 · 슈퍼바이저/내담자/평가</div>
</div>
<!-- Phase 3 -->
<div class="phase">
<div class="ph-top"><span class="ph-id">PHASE 3</span><span class="ph-pct">0%</span></div>
<div class="ph-name">효과검증 파일럿</div>
<div class="ph-weeks">16~20주차</div>
<div class="track"><div class="fill" style="width:0%"></div></div>
<div class="ph-state wait"><span class="d"></span>대기</div>
<div class="ph-note">IRB 면제 확정 (교육용)</div>
</div>
<main>
<div class="hero">
<div>
<div class="kicker">team lead review</div>
<h1>Phase 0는 <em>검증 가능한 운영 안전장치와 E2E 안정화</em>까지 마무리됐다.</h1>
<p class="lead">
이번 세션은 seed persona 결합 제거, 비개발 fail-closed 설정, compose OAuth 주입,
admin 모바일 overflow 수정, persona 인증 경계 보강, 전체 Playwright 재통과, 공개 API prod 전환까지 완료했다.
이후 로컬 5175 런타임 재기동, admin 사용자 관리 E2E 안정화, 학습자 홈 0회 기록 UX,
active session 실제 SSE 스트림 전환, 텍스트/음성 실패 턴 미저장, 직접 URL persona 우회 차단, Live2D demo fallback 제거,
AI 운영 fail-closed 보강까지 전체 Playwright로 확인했다.
2026-06-26 14:43 KST 기준 공개 API 터널을 복구한 뒤 전체 Playwright 72개가 desktop/mobile/single-run 모두 통과했다.
공개 Google OAuth 실제 `/turn` 증명만 storageState 캡처 후 남아 있다.
</p>
</div>
<div class="summary" aria-label="현재 검증 요약">
<div class="metric"><span>Full Playwright</span><b>72 passed</b></div>
<div class="metric"><span>Public API</span><b>prod OK</b></div>
<div class="metric"><span>Backend unittest</span><b>20 OK</b></div>
<div class="metric"><span>Decision</span><b>조건부 GO</b></div>
</div>
</div>
</section>
<!-- 2단 본문 -->
<div class="cols">
<!-- 좌측: 오늘 완료 + 진행중/다음 -->
<div>
<section class="block">
<div class="sechead">
<div class="kicker"><span class="dot"></span>오늘 완료 · 2026-06-25</div>
<div class="desc">P0 기반정렬에서 오늘 자로 닫은 항목들입니다.</div>
<section>
<div class="section-head">
<div>
<h2>팀장 판정</h2>
<p>이 HTML만 보고 판단해야 하는 항목</p>
</div>
</div>
<div class="grid">
<article class="panel">
<span class="tag done">go</span>
<h3>로컬 회귀와 공개 prod-safe 게이트 통과</h3>
<p>백엔드 unittest 20개, Python compile, 웹 production build, 전체 Playwright 72개가 통과했다. 공개 API도 `environment:"prod"`, `db:true`, `engine:true`로 응답한다.</p>
</article>
<article class="panel">
<span class="tag doing">conditional</span>
<h3>운영 반영 전 남은 증거 1개</h3>
<p>실제 Google 로그인 쿠키가 필요한 공개 `/turn` smoke는 아직 storageState가 없어 미완이다. 로그인 가능한 계정으로 한 번 캡처하면 바로 검증 가능하다.</p>
</article>
<article class="panel">
<span class="tag info">mock audit</span>
<h3>런타임 목업 제거 기준</h3>
<p>프론트 페이지는 실제 API/DB 경로를 탄다. 남은 `mock` 표기는 테스트 코드의 `unittest.mock` 또는 의도적 fixture이며, seed fallback은 prod/staging에서 fail-closed 된다.</p>
</article>
</div>
</section>
<div class="panel">
<div class="checks">
<!-- 설계 -->
<div class="chk-group">
<div class="chk-glabel">설계 4종</div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">마스터플랜</span></div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">적대검증 — red team <b>40개 결함</b> 도출 후 재설계</span></div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">메모리 · 페르소나 설계</span></div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">디자인 컨셉</span></div>
</div>
<!-- 인프라 -->
<div class="chk-group">
<div class="chk-glabel">인프라</div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">모노레포 repo 생성</span></div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">Docker 이식 구성 <span class="ct" style="color:var(--ink-3)">— compose + Caddy + 엔진 어댑터 분리</span></span></div>
</div>
<!-- 디자인 -->
<div class="chk-group">
<div class="chk-glabel">디자인</div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">화면 <b>7종</b> 시안 <span class="tag">login · learner-home · session · session-review · professor · admin · settings</span></span></div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">아바타 <b>Rive</b> 채택</span></div>
</div>
<!-- 엔진 -->
<div class="chk-group">
<div class="chk-glabel">엔진</div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct"><code style="font-family:var(--font-num);font-size:12px;background:var(--paper-2);padding:1px 6px;border-radius:5px;">claude -p</code> 상주 멀티턴 게이트웨이 <span class="tag verified">실동작 검증 완료</span><br><span style="font-size:12px;color:var(--ink-3);">컨텍스트 유지 + 캐시 재사용 확인</span></span></div>
</div>
<!-- P0 코드 -->
<div class="chk-group">
<div class="chk-glabel">P0 코드 · 구문검증 통과</div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">DB 스키마 <b>35테이블</b> <span style="color:var(--ink-3);font-variant-numeric:tabular-nums;">(727줄)</span></span></div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">FastAPI 골격</span></div>
<div class="chk"><span class="mk"><svg viewBox="0 0 24 24"><polyline points="20 6 9 17 4 12" stroke-linecap="round" stroke-linejoin="round"/></svg></span><span class="ct">taxonomy <span style="color:var(--ink-3);font-variant-numeric:tabular-nums;">(285줄)</span></span></div>
</div>
</div>
<section>
<div class="section-head">
<div>
<h2>완료</h2>
<p>새 세션에서 반복하지 않아도 되는 작업</p>
</div>
</section>
</div>
<div class="grid">
<article class="panel">
<span class="tag done">E2E persona decoupled</span>
<h3>DB persona 기반 테스트</h3>
<p>`session-persistence`, `teacher`, `voice`는 `/api/personas`에서 실제 DB persona를 가져온다.</p>
</article>
<article class="panel">
<span class="tag done">fail-closed</span>
<h3>비개발 런타임 보호</h3>
<p>prod/staging에서 dev login, seed fallback, OAuth 누락, 기본 secret, localhost CORS를 거부한다.</p>
</article>
<article class="panel">
<span class="tag done">infra</span>
<h3>compose OAuth 주입</h3>
<p>`ENVIRONMENT`, Google OAuth vars, `CORS_ORIGINS`, `OPENAI_BASE_URL`을 API 컨테이너에 명시했다.</p>
</article>
<article class="panel">
<span class="tag done">admin UX</span>
<h3>모바일 overflow 수정</h3>
<p>긴 readiness detail을 안정화했고, 사용자 변경은 DB 저장소가 durable할 때만 허용한다. AI 운영은 실제 어댑터명과 health 상태를 표시한다.</p>
</article>
<article class="panel">
<span class="tag done">test isolation</span>
<h3>global engine config 분리</h3>
<p>`db-persistence`는 전역 engine config를 바꾸므로 `@single-run`으로 분리했다.</p>
</article>
<article class="panel">
<span class="tag done">docs</span>
<h3>handoff 갱신</h3>
<p>`docs/HANDOFF.md`, `docs/dev_dashboard.html`, `apps/web/e2e/README.md`를 최신 상태로 맞췄다.</p>
</article>
<article class="panel">
<span class="tag done">public prod</span>
<h3>공개 API prod 전환</h3>
<p>`cloudflared` ingress를 `127.0.0.1:8001` prod API로 돌렸고 public readiness gate가 통과했다.</p>
</article>
<article class="panel">
<span class="tag done">ops script</span>
<h3>공개 런타임 재기동</h3>
<p>`scripts/start-public-runtime.ps1`와 `scripts/stop-public-runtime.ps1`로 API 8001과 cloudflared를 재현 가능하게 만들었다.</p>
</article>
<article class="panel">
<span class="tag done">auth boundary</span>
<h3>persona catalog 인증화</h3>
<p>`/personas`는 이제 비로그인 401이다. 로컬과 공개 API 모두 직접 확인했고 E2E 회귀를 추가했다.</p>
</article>
<article class="panel">
<span class="tag done">public web</span>
<h3>API base fallback 보강</h3>
<p>public 웹에서 `/api/auth/config`가 SPA HTML로 떨어지지 않도록 public API origin fallback을 추가했다.</p>
</article>
<article class="panel">
<span class="tag done">learner UX</span>
<h3>0회 기록 상태 보강</h3>
<p>학습자 홈은 이제 fake 기록 없이 실제 서버 세션 0개를 `기존 회기` 빈 상태와 0 카운트로 표시한다.</p>
</article>
<article class="panel">
<span class="tag done">local login</span>
<h3>5175 로컬 재기동 검증</h3>
<p>`127.0.0.1:5175` web, `8000` API, `9099` engine을 재기동하고 dev-login 서버 세션과 `/learn` redirect를 확인했다.</p>
</article>
<article class="panel">
<span class="tag done">session UX</span>
<h3>dense viewport 보정</h3>
<p>375px급 active session에서 stage 내부가 1px 넘게 잘리던 문제를 avatar/padding 조정으로 제거했다.</p>
</article>
<article class="panel">
<span class="tag done">real stream</span>
<h3>텍스트 턴 SSE 전환</h3>
<p>`POST /sessions/{id}/stream` token 이벤트로만 내담자 자막을 쌓고, HTTP 거절과 SSE error 모두 실패 턴을 transcript와 저장소에 남기지 않는다.</p>
</article>
<article class="panel">
<span class="tag done">voice turn</span>
<h3>음성 실패 턴 미저장</h3>
<p>음성 회기도 엔진 응답 생성 성공 후에만 learner/client turn을 저장한다. 실패한 transcript는 pending 상태에서 제거한다.</p>
</article>
<article class="panel">
<span class="tag done">ops copy</span>
<h3>비영구 상태 문구 정리</h3>
<p>admin health와 voice degraded reason은 내부 fallback/seed 표현 대신 비영구 런타임 상태를 사용자용 문구로 표시한다.</p>
</article>
<article class="panel">
<span class="tag done">route guard</span>
<h3>직접 URL persona 차단</h3>
<p>`/learn/session/:code`로 직접 들어와도 DB 원본 persona가 아니면 회기 시작을 막는다. degraded/seed fallback 회귀를 추가했다.</p>
</article>
<article class="panel">
<span class="tag done">avatar</span>
<h3>demo Live2D fallback 제거</h3>
<p>Mao/Haru 샘플은 공용 fallback으로 쓰지 않는다. 실제 persona별 `live2dModelUrl`이 있을 때만 Live2D를 렌더링한다.</p>
</article>
</div>
</section>
<section class="block">
<div class="sechead">
<div class="kicker"><span class="dot"></span>진행중 · 다음</div>
<div class="desc">P0 마무리와 P1 진입 작업.</div>
<section>
<div class="section-head">
<div>
<h2>검증</h2>
<p>마지막으로 직접 실행한 결과</p>
</div>
</div>
<table>
<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>Backend</td><td><code>python -m unittest app.test_runtime_policy app.test_session_turn_persistence engine_gateway.test_gateway_model</code></td><td>20 tests OK</td></tr>
<tr><td>Compile</td><td><code>python -m compileall app engine_gateway</code></td><td>Passed</td></tr>
<tr><td>Web build</td><td><code>npm run build</code></td><td>Passed</td></tr>
<tr><td>Compose</td><td><code>docker compose --env-file .env.example config --quiet</code></td><td>Passed with dummy required env</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>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>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 script</td><td><code>scripts\start-public-runtime.ps1</code></td><td>API 8001 + cloudflared running</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>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>Voice/session focused</td><td><code>voice + voice-success + session-layout</code></td><td>15 passed</td></tr>
<tr><td>Latest focused E2E</td><td><code>learner + session-layout + settings + admin</code></td><td>37 passed</td></tr>
</tbody>
</table>
</section>
<div class="panel">
<div class="nextlist">
<div class="nx">
<span class="nd doing"></span>
<span class="nt">DB 실제 마이그레이션 <span class="st blocked">블로커</span>
<span class="why">Docker Desktop 띄워 psql 적용 — Docker 꺼져 있어 미완</span>
</span>
</div>
<div class="nx">
<span class="nd queued"></span>
<span class="nt">상담 루프 연결 <span class="st queued">P1</span>
<span class="why">엔진 + 상태머신 + 실시간 피드백</span>
</span>
</div>
<div class="nx">
<span class="nd queued"></span>
<span class="nt">Rive 아바타 통합 <span class="st queued">P1</span></span>
</div>
<div class="nx">
<span class="nd queued"></span>
<span class="nt">OpenAI 음성 캐스케이드 <span class="st queued">P1</span></span>
</div>
<div class="nx">
<span class="nd queued"></span>
<span class="nt">React 프론트 연결 <span class="st queued">P1</span></span>
</div>
</div>
<section>
<div class="section-head">
<div>
<h2>남은 리스크</h2>
<p>다음 세션 첫 작업</p>
</div>
</section>
</div>
</div>
<div class="grid">
<article class="panel">
<span class="tag risk">blocker</span>
<h3>public `/turn` proof 없음</h3>
<p>2026-06-26 14:43 KST 기준 Google 로그인 storageState가 없어 공개 API `/turn` 실제 성공 증거가 아직 없다.</p>
</article>
<article class="panel">
<span class="tag doing">ops</span>
<h3>공개 API 프로세스 유지</h3>
<p>터널은 `127.0.0.1:8001` prod API를 본다. 재부팅 후에는 prod API와 cloudflared를 같이 재기동해야 한다.</p>
</article>
<article class="panel">
<span class="tag doing">check before deploy</span>
<h3>engine config 재확인</h3>
<p>E2E가 admin engine config를 건드린다. 배포 전 의도한 model/url/mode인지 다시 확인해야 한다.</p>
</article>
</div>
</section>
<!-- 우측: 확정 결정 + 리스크/블로커 -->
<div>
<section class="block">
<div class="sechead">
<div class="kicker"><span class="dot"></span>확정 결정</div>
<div class="desc">되돌리지 않는 아키텍처·운영 기준선.</div>
<section>
<div class="section-head">
<div>
<h2>다음 명령</h2>
<p>공개 OAuth smoke</p>
</div>
</div>
<div class="commands">
<pre>cd D:\workspace\vignette\apps\web
npx playwright codegen https://vignette.chanpaca.net/login --save-storage=.\node_modules\.tmp\public-auth.json
<div class="panel">
<div class="decisions">
<div class="dc">
<span class="dk">엔진</span>
<span class="dv">로컬 <b>claude -p</b> 상주풀 <span style="color:var(--ink-3);">(API 폴백)</span></span>
</div>
<div class="dc">
<span class="dk">아바타</span>
<span class="dv"><b>Rive</b></span>
</div>
<div class="dc span">
<span class="dk">기술 스택</span>
<span class="dv">
<span class="stack-chips">
<span class="c key">React 19</span>
<span class="c key">FastAPI</span>
<span class="c key">PG 16 + pgvector</span>
<span class="c">Docker</span>
</span>
</span>
</div>
<div class="dc span">
<span class="dk">운영 디폴트 6종</span>
<span class="dv" style="font-size:13px;color:var(--ink-2);line-height:1.65;">
s2s 2차 · 평가 AI 로컬 · 재귀학습 few-shot · 자유연습 ON (교수 토글) · 스케일업 compose
</span>
</div>
</div>
$env:E2E_PUBLIC_AUTH="1"
$env:E2E_PUBLIC_STORAGE_STATE=".\node_modules\.tmp\public-auth.json"
.\node_modules\.bin\playwright.cmd test e2e/public-auth-turn.spec.ts --project=chromium-public-auth</pre>
<pre>cd D:\workspace\vignette
powershell -NoProfile -ExecutionPolicy Bypass -File scripts\start-public-runtime.ps1</pre>
</div>
</section>
<section>
<div class="section-head">
<div>
<h2>주의</h2>
<p>작업 방식</p>
</div>
</section>
<section class="block">
<div class="sechead">
<div class="kicker"><span class="dot"></span>리스크 · 블로커</div>
<div class="desc">빨강은 데이터·외부 의존, 주황은 검증 미완.</div>
</div>
<div class="panel">
<div class="risks">
<div class="rk">
<span class="sev high"><span class="d"></span>HIGH</span>
<span class="rbody">
<span class="rt">데이터 n=1</span>
<span class="rd">0615 단일 축어록뿐. 추가 사례 수급은 한신대 협의 필요 <span class="ext">외부 블로커</span></span>
</span>
</div>
<div class="rk">
<span class="sev mid"><span class="d"></span>MID</span>
<span class="rbody">
<span class="rt">DB 실마이그레이션 미검증</span>
<span class="rd">Docker Desktop 기동 후 psql 적용 필요 — 현재 Docker 꺼짐</span>
</span>
</div>
<div class="rk">
<span class="sev mid"><span class="d"></span>MID</span>
<span class="rbody">
<span class="rt">미성년 사례데이터 활용동의</span>
<span class="rd">한신대 공문 필요 <span class="ext">외부 협의</span></span>
</span>
</div>
</div>
</div>
</section>
</div>
</div>
<!-- 3. 산출물 인벤토리 -->
<section class="block">
<div class="sechead">
<div class="kicker"><span class="dot"></span>산출물 인벤토리</div>
<div class="desc">repo 경로 기준. 카드가 아니라 표로 — 전체를 한 번에 훑습니다.</div>
</div>
<table class="inv-tbl">
<thead>
<tr>
<th style="width:120px;">영역</th>
<th>내용</th>
<th>repo 경로</th>
<th style="width:88px;">상태</th>
</tr>
</thead>
<tbody>
<tr>
<td class="area"><span class="kd"><span class="d"></span>설계 문서</span></td>
<td class="what">마스터플랜 · 적대검증(red team) · 메모리·페르소나 설계 · 디자인 컨셉</td>
<td class="path"><code>docs/MASTERPLAN.md</code><code>docs/redteam/</code><code>docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md</code><code>docs/DESIGN_CONCEPT.md</code></td>
<td class="stt"><span class="invtag done"><span class="d"></span>완료</span></td>
</tr>
<tr>
<td class="area"><span class="kd"><span class="d"></span>디자인 시안</span></td>
<td class="what">화면 7종 목업 (login · learner-home · session · session-review · professor · admin · settings)</td>
<td class="path"><code>docs/mockups/</code> <span style="color:var(--ink-3);font-family:var(--font-num);">(7)</span></td>
<td class="stt"><span class="invtag done"><span class="d"></span>완료</span></td>
</tr>
<tr>
<td class="area"><span class="kd"><span class="d"></span>인프라</span></td>
<td class="what">Docker 이식 구성 — compose + Caddy + 엔진 어댑터 분리</td>
<td class="path"><code>infra/</code></td>
<td class="stt"><span class="invtag partial"><span class="d"></span>구성 완료 · 마이그레이션 미완</span></td>
</tr>
<tr>
<td class="area"><span class="kd"><span class="d"></span>엔진 · 백엔드</span></td>
<td class="what">claude -p 상주 게이트웨이 (실동작 검증) + FastAPI 골격 + DB 스키마 35테이블 + taxonomy</td>
<td class="path"><code>apps/api/</code></td>
<td class="stt"><span class="invtag partial"><span class="d"></span>골격 · 구문검증 통과</span></td>
</tr>
</tbody>
</table>
<div class="foot">
<span>repo · github.com/yunchan8804-blip/vignette (private)</span>
<span class="fdot"></span>
<span>기준일 2026-06-25</span>
<span class="fdot"></span>
<span>Vignette 개발팀 내부 PM 보드</span>
</div>
</section>
</div>
</div>
<ul class="list">
<li>`frontenddesign` 스킬은 현재 세션에 없었다. 다음 세션에서 보이면 먼저 사용하고, 없으면 `docs/DESIGN_CONCEPT.md`를 따른다.</li>
<li>학습자 홈과 active session UX는 사용자가 강하게 불만을 말한 영역이다. fake 기록/통계 금지, 0회 기록은 실제 서버 세션 0개 기반 빈 상태로 유지, active session 전체화면/무문서스크롤 유지.</li>
<li>worktree는 원래부터 dirty다. 관련 없는 변경을 되돌리지 말 것.</li>
<li>OAuth secret은 출력하지 말 것. 실제 secret은 환경 파일/인프라 설정에만 둔다.</li>
</ul>
</section>
</main>
</body>
</html>