대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·misconduct 반응 + 게이트웨이 격리·RAG 비차단 수정

SSOT 대시보드:
- 한신대 기술분석 PDF(19쪽) 정합성 분석 + 이번 세션 발견 섹션 추가
- 섹션 폴드아웃(접기)·상단 목차(드릴다운)·모두 펼치기/접기 — 내용 보존, 레이아웃만 정리

페르소나 반응 강화('저항·반응 조절' 핵심 차별):
- PersonaCard.triggers(역린) 필드 + CCD 핵심상처 파생 역린 블록
- L0에 무례·모욕·조롱 시 현실적 동맹 균열 반응 지침

버그·성능 수정(라이브/E2E로 포착):
- 게이트웨이 페르소나 격리: --append-system-prompt를 --system-prompt(교체)로 + --exclude-dynamic-system-prompt-sections (내담자 캐릭터 붕괴·개발맥락 누출 차단)
- RAG: 임베더 동기 로드(약 7-13초)를 _warm_rag_caches 백그라운드 warm으로(세션 생성 블로킹 회귀 수정)
- voice TTS RMS 데드힌트 제거, init_state OpennessParams 파라미터객체화
- 한국어 PII(날짜·금액·주소) 마스킹 보강
- 레이아웃 시각 게이트: 폼 컨트롤 값 스크롤 오탐 제외(7/7)

검증: 백엔드 84/84, E2E 42(데스크톱 27·모바일 11·아바타 4), 시각 게이트 7/7
This commit is contained in:
Yun Chan 2026-06-27 02:30:46 +09:00
parent cb2aebd76c
commit 085460b5e0
327 changed files with 31226 additions and 1829 deletions

View file

@ -27,7 +27,7 @@ AUTO_SEED_PERSONAS=false
ALLOW_SEED_PERSONA_FALLBACK=false
EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false
FRONTEND_BASE_URL=http://localhost:5173
CORS_ORIGINS=["http://localhost:5173","http://127.0.0.1:5173"]
CORS_ORIGINS=["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"]
# Live2D runtime. Models must be configured per persona via live2dModelUrl.
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js

3
.gitignore vendored
View file

@ -47,3 +47,6 @@ apps/api/gateway.restart.*
apps/api/e2e_*.py
apps/web/_pptr_check.cjs
apps/web/_pptr*.cjs
# 로컬 dev 서버 로그(scripts/dev-up.ps1)
.devlogs/

55
AGENT.md Normal file
View file

@ -0,0 +1,55 @@
# AGENT.md — 에이전트 운영 수칙 (Vignette)
이 저장소에서 자동화 에이전트/서브에이전트가 일할 때의 운영 수칙. 상세 프로젝트
지침은 [`CLAUDE.md`](./CLAUDE.md) 참조.
**작업 전 관련 가이드를 먼저 읽어라**: [`README.md`](./README.md) ·
[로컬 실행](./docs/guides/local-development.md) · [아키텍처](./docs/guides/architecture.md) ·
[테스트](./docs/guides/testing.md) · [원천문서·갭](./docs/guides/source-docs-and-gaps.md) ·
SSOT [`docs/dev_dashboard.html`](./docs/dev_dashboard.html). 동작/구조 변경 시 해당 문서와 SSOT를 갱신.
---
## ⚠️ 규칙 0 — 무조건 OS를 먼저 파악한다 (필수, 최우선)
**모든 작업의 첫 단계는 OS·셸·경로·도구 환경 확정이다.** 명령을 한 줄이라도
실행하기 전에 다음을 확인하라. 생략하면 환경 차이로 반드시 시간을 버린다.
- [ ] **OS / 셸 확인** — 주 환경은 **Windows 11 + PowerShell**. POSIX 가정 금지.
- [ ] **경로 규칙** — Windows 절대경로, 한글·공백 경로 빈번. `-LiteralPath` 사용,
외부 도구엔 **ASCII 이름으로 로컬 복사 후** 전달.
- [ ] **PowerShell 5.1 함정** — 인라인 if/else·삼항 없음, 네이티브 stderr `2>&1` 금지,
파일 출력은 `-Encoding utf8`.
- [ ] **외부 CLI 블로킹 검증** — GUI 런처는 즉시 detach. 실제 작업 바이너리
(예: `soffice.bin`)를 직접 호출하고 `-Wait` 동작을 확인. 좀비/락 먼저 정리.
- [ ] **도구 가용성 탐지 우선** — 변환·처리 전 LibreOffice/pandoc/python lib/
Playwright 브라우저 설치 여부와 경로를 먼저 잡는다.
> **"OS·셸·경로·도구를 확정한 뒤에 실행한다. 추정으로 시작하지 않는다."**
---
## 규칙 1 — 증거 정직성
- 가짜 증거로 DONE 표기 금지. 실증 불가/외부 의존/소유자 결정 항목은
`docs/ops/backlog-*.md`에 분류·추적.
- 변경 후 검증(typecheck / E2E 게이트)을 실제로 돌리고 결과를 그대로 보고.
## 규칙 2 — 범위·권한
- 소유자(윤찬) 단독 결정 사안은 임의 결정 금지.
- 전 페이지 공용 셸 변경 등 광범위 영향 작업은 회귀 검증을 동반.
## 규칙 3 — 출력
- 한글로 소통. 커밋 메시지에 Claude/Co-Authored-By 문구 금지.
## 규칙 4 — 이미지 생성 / 아바타 리깅
- "이미지 생성·만들어·그려줘" 요청 → `~/.claude/skills/codex-image` 스킬 사용.
gpt-image-2 래퍼 `~/.codex/imagegen-headless/codex_imagegen.sh`(ChatGPT 구독 인증, **API 키 금지**).
codex 0.140+는 결과가 rollout JSONL에 base64로 인라인 → 래퍼의 추출 스크립트만 결정적(직접 `codex exec` 금지).
- 누끼: `object-separation` 스킬(BiRefNet, `~/.venvs/object-separation`).
- 아바타 래스터 리깅(파츠 분리) 파이프라인·재현 절차는 **CLAUDE.md §4**
[`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조.
- 비전(analyze_image) 도구가 다중 패널/캐릭터를 자주 혼동 → 시각 판단은 **사용자 확인 + 스크린샷** 우선.

101
CLAUDE.md Normal file
View file

@ -0,0 +1,101 @@
# CLAUDE.md — Vignette 프로젝트 작업 지침
> Vignette = AI 심리상담 시뮬레이션 훈련 플랫폼 (한신대 산학협력).
> 모노레포: `apps/api`(FastAPI/Python), `apps/web`(React 19/Vite/Playwright), `docs`, `infra`, `scripts`.
## 📚 문서 맵 — 작업 전 해당 가이드를 먼저 읽어라
| 목적 | 문서 |
|---|---|
| 저장소 개요·빠른 시작 | [`README.md`](./README.md) |
| **로컬 서버 띄우기·테스트** | [`docs/guides/local-development.md`](./docs/guides/local-development.md) |
| 시스템 아키텍처·데이터 흐름 | [`docs/guides/architecture.md`](./docs/guides/architecture.md) |
| 테스트·검증 실행 | [`docs/guides/testing.md`](./docs/guides/testing.md) |
| 원천문서·갭 로드맵 | [`docs/guides/source-docs-and-gaps.md`](./docs/guides/source-docs-and-gaps.md) |
| **SSOT 상태판** | [`docs/dev_dashboard.html`](./docs/dev_dashboard.html) |
| 백로그 | [`docs/ops/backlog-2026-06-26.md`](./docs/ops/backlog-2026-06-26.md) |
| **서연 아바타 핸드오프** | [`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) |
작업 결과로 동작/구조가 바뀌면 해당 가이드와 SSOT 대시보드를 함께 갱신한다.
---
## ⚠️ 0. 무조건 OS를 먼저 파악하고 시작한다 (최우선·필수)
**어떤 작업이든 명령을 실행하기 전에 OS와 셸을 먼저 확정하라.** 이걸 건너뛰면
경로/인코딩/도구 차이로 시간을 크게 낭비한다(실제로 그랬다).
작업 시작 시 반드시 확인할 것:
1. **OS / 셸**: 이 저장소의 주 개발 환경은 **Windows 11 + PowerShell**이다.
POSIX를 가정하지 마라. Bash 도구도 쓸 수 있으나 셸마다 문법이 다르다.
2. **경로 규칙**: Windows 절대경로(`D:\...`, `C:\...`). 한글·공백 포함 경로가 흔하다
(예: OneDrive `문서\카카오톡 받은 파일`). `-LiteralPath`로 다루고, 외부 도구에
넘기기 전에 **ASCII 이름으로 로컬 복사**해 인코딩/공백 문제를 차단하라.
3. **PowerShell 판(5.1) 주의**: 인라인 `if(){}else{}`를 식으로 못 쓴다(삼항 없음).
네이티브 exe stderr를 `2>&1`로 합치지 마라(ErrorRecord로 감싸짐).
기본 출력 인코딩은 UTF-16 — 다른 도구가 읽을 파일은 `-Encoding utf8`.
4. **외부 CLI는 실제로 블로킹되는지 확인**: GUI 런처(`soffice.exe` 등)는 즉시
detach되어 `Start-Process -Wait`가 변환을 안 기다린다. 실제 작업 프로세스
(`soffice.bin`)를 직접 호출하라. 좀비 프로세스가 락을 잡으면 정리부터 한다.
5. **도구 가용성 먼저 탐지**: 변환/처리 전에 LibreOffice·pandoc·python 라이브러리·
Playwright 브라우저 등 무엇이 설치돼 있는지 먼저 확인하고 경로를 잡아라.
> 한 줄 요약: **"먼저 OS·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지.
---
## 1. 운영 원칙
- **가짜 증거로 DONE 표기 금지.** 실증/외부 의존/소유자 결정이 필요한 항목은
`docs/ops/backlog-*.md`에 분류해 추적한다(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스).
- **`docs/dev_dashboard.html`이 SSOT(단일 진실 공급원)다.** 상태·검증 증거·결정 필요·로드맵의 권위 기준이며, 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 대시보드에 반영/동기화한다. 백로그(`docs/ops/backlog-*.md`)는 대시보드와 일치시킨다(어긋나면 대시보드 기준).
- 소유자(윤찬) 단독 결정 사안을 임의로 정하지 않는다(월권 금지).
## 2. 검증 기준 (프론트 변경 시)
- `cd apps/web && npm run typecheck`
- 레이아웃 변경은 `e2e/layout-visual-gate.spec.ts`(7/7) + 레이아웃 포커스 E2E +
`e2e/session-layout.spec.ts`(8/8) 무회귀. E2E는 web+api(+DB) 스택이 떠 있어야 한다.
## 3. 커뮤니케이션
- 모든 대화·주석·커밋 메시지는 한글.
- git 커밋 메시지에 Co-Authored-By / Claude 관련 문구 추가 금지.
---
## 4. 이미지 생성(gpt-image-2 = imagegen2) · Live2D식 아바타
> "이미지 생성해/만들어/그려줘" 요청 → `~/.claude/skills/codex-image` 스킬이 아래 래퍼를 자동 사용.
### 4.1 gpt-image-2 호출 (반드시 래퍼)
```bash
bash ~/.codex/imagegen-headless/codex_imagegen.sh \
--out <경로.png> [--size WxH] [--quality low|medium|high|auto] \
[-i <참조이미지> ...] [--all] "<프롬프트>"
```
- 인증: ChatGPT 구독 OAuth(`~/.codex/auth.json``auth_mode=="chatgpt"`). **API 키 사용 금지**(과금).
- codex 0.140+는 생성 이미지를 세션 rollout JSONL에 **base64로 인라인 반환** → 래퍼의 `extract_imagegen.py` 추출만 결정적. stdout의 "저장 경로"는 **환각**(직접 codex exec 금지).
- 프롬프트는 **stdin 파이프**로(인자 전달 시 멈춤). 변주 생성 시 base를 `-i` 참조로 넘겨 아이덴티티·프레이밍 고정.
- 투명배경 미지원 → 단색 평면 배경으로 생성 후 누끼. 한글 텍스트 렌더 가능(stdin 파이프라 인코딩 문제 없음).
### 4.2 누끼(컷아웃)
- `object-separation` 스킬(BiRefNet): `~/.venvs/object-separation/Scripts/python.exe ~/.agents/skills/object-separation/scripts/separate_object.py <in> <out> --model birefnet-general`
- 알파 정제(잔류 헤이즈 제거): 임계치 `<35→0, >205→255` + 페더(`docs/avatar-art/seoyeon/publish.py` 참조).
### 4.3 Live2D식 아바타(파츠 분리 리깅)
아바타는 기본 **SVG 파라미터 리그** 또는 **래스터 파츠 분리 리깅**(`apps/web/src/components/avatar/RasterBust.tsx`)으로 렌더. 후자는 `persona.rasterArtSet` 지정 시 활성.
- 레이어(각각 독립 opacity/교체 → 표정 중에도 깜빡임·입술싱크가 따로 움직임):
`base(neutral 전신)` + `upperface-<표정>(눈썹+눈)` + `eyelid-closed(깜빡임, 표정 무관)` + `mouth-<표정>` + `mouth-open(립싱크)`.
- 파이프라인(재현 스크립트는 `docs/avatar-art/seoyeon/`):
1. `codex_imagegen.sh`로 base + 표정 변주(sad/tired/anxious/warm/startled/eyes-closed/speaking) 생성. 변주는 base를 `-i` 참조로, 동일 평면 배경.
2. BiRefNet 누끼 → `publish.py`(표준 캔버스 900×1125 정규화 + 알파 정제)로 `apps/web/public/avatar/<artSet>/` 게시.
3. `make-parts.py`로 특징 영역(upperface/eyelid/mouth) 크롭+페더 파츠를 `parts/` 생성(영역 상수 튜너 블럭).
- 연결: `persona.ts``AvatarPersona.rasterArtSet` / `Session.tsx``PERSONA_AVATAR_LOOKS[<code>].rasterArtSet` / `RasterBust.tsx`(28표정→클러스터 매핑 포함).
- dev 미리보기(인증 없음): `/dev/avatar-preview`(`AvatarPreview.tsx`). 스크린샷: `node apps/web/scripts/avatar-shot.mjs`(BASE_URL 환경변수로 포트 지정).
- 실제 Live2D Cubism(`.moc3`)은 편집기 저작이 필요해 자동화 불가 → 위 레이어 합성이 실용적 대안.
### 4.4 진행 중인 아바타 작업 핸드오프
서연(P1) 아바타 작업은 **별도 세션에서 진행**. 현재 상태·남은 작업(Image #2=짧은 보브 기준 재생성 등)은
[`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조.

163
README.md
View file

@ -1,46 +1,143 @@
# Vignette
# Vignette 저장소 README
> AI 심리상담 시뮬레이션 훈련 플랫폼 — 한신대 SW중심대학 산학협력 (트웬티온스)
> **Vignette** — AI 심리상담 시뮬레이션 훈련 플랫폼.
> 한신대학교 산학협력(구훈정 교수) 프로젝트. 상담 수련생(학습자)이 **AI 내담자 페르소나**와
> 회기를 진행하고, 백그라운드 평가 엔진이 **회기 리뷰 피드백**을 제공한다.
> "임상 비네트(사례 삽화)"로 안전하게 연습한다는 의미에서 *Vignette*.
상담 수련생이 **가상 내담자 AI**와 음성으로 상담을 연습하고, **백그라운드 평가 AI**가 실시간·회기말 피드백을 준다. "임상 비네트(사례 삽화)"로 안전하게 연습한다는 의미에서 *Vignette*.
핵심 구성: 페르소나 엔진(`persona_repository`, SEED P1~P3) · 이론모드(humanistic 등) ·
오케스트레이터(`services/orchestrator.py`, `prepare_turn`/`run_turn_generate`, `eval_hook`/`log_hook` 주입형) ·
저항엔진(state_machine openness) · 마스킹 게이트(PII, Presidio + 정규식) ·
음성 캐스케이드(STT/TTS, `voice.py`) · 회기 리뷰(`evaluator.py` deep-loop + `make_eval_hook` fast-loop) ·
평가 KPI(SUS·자기효능감·κ/ICC·환각률) · 재귀학습(`ds.*` 스키마) · 데이터/SSO 거버넌스(`saml.py`, auth allowlist).
## 구조 (모노레포)
AI 턴 생성 엔진은 별도 서비스인 **engine_gateway**(포트 9099)가 담당하며
`ENGINE_MODE`로 백엔드를 고른다(`claude_cli` / `claude_api` / `openai` / `solar`).
저장소(SoR)는 **PostgreSQL 16 + pgvector** 단일 출처, 미가용 시 `store.py` 인메모리 degraded 폴백.
```
vignette/
├ apps/
│ ├ web/ React 19 프론트엔드 (3역할: 관리자/교수자/학습자, 추후 RN 네이티브)
│ └ api/ FastAPI 백엔드 (엔진 어댑터·상태머신·가드레일·RAG)
├ infra/ Docker Compose (web·api·postgres·voice gateway)
└ docs/ 설계 문서 (SoT)
```
---
## 핵심 설계 (docs/)
## 1. 모노레포 구조
| 문서 | 내용 |
| 경로 | 설명 |
|---|---|
| `docs/MASTERPLAN.md` | 시스템 마스터플랜 (아키텍처·로드맵·스택) |
| `docs/redteam/REDTEAM_FINDINGS.md` | 적대검증 40결함 |
| `docs/redteam/MASTERPLAN_REVISIONS.md` | 재설계 패치 (claude -p 1급 엔진 복원 등) |
| `docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md` | 메모리·지식·페르소나 (회기 간 연속성) |
| `docs/DESIGN_CONCEPT.md` | 디자인 컨셉 (토큰·아바타·화면) |
| `docs/mockups/` | 화면 레이아웃 시안 (HTML) |
| `apps/api/` | FastAPI/Python 백엔드. 오케스트레이터·페르소나·저항엔진·마스킹·음성·평가·인증. 엔진 게이트웨이(`apps/api/engine_gateway/`) 포함 |
| `apps/web/` | React 19 + Vite 프론트엔드(3역할: 관리자/교수자/학습자). Playwright E2E |
| `docs/` | 설계·운영 문서. `docs/dev_dashboard.html`이 SSOT(단일 진실 공급원) |
| `infra/` | Docker Compose 스택(`db`=pgvector pg16, `api`, `web`, `rag`, `proxy`=Caddy) 및 `.env.example` |
| `scripts/` | 운영 스크립트(PowerShell/Python): 공개 런타임 기동·감시, 엔진 게이트웨이 프로브, Postgres RLS 감사 등 |
## 확정 스택
---
- **엔진**: 로컬 Opus 4.8 `claude -p` 상주 멀티턴 풀(`--input-format stream-json`), Anthropic Messages API 폴백
- **프론트**: React 19 + SSE (pnpm/Turborepo 모노레포, 추후 React Native)
- **백엔드**: FastAPI + SSE 스트리밍
- **DB**: NAS PostgreSQL 16 + pgvector
- **RAG**: BGE-M3 + 하이브리드 + Contextual Retrieval + BGE-reranker-v2-m3
- **음성**: OpenAI 캐스케이드(STT→LLM→TTS), 멀티보이스 + 페르소나
- **인증**: OAuth 2.1 (BFF, 3역할 RBAC, visible_to 정보비대칭)
- **배포**: Docker Compose, chanpaca.net 외부노출(교수 테스트)
## 2. 빠른 시작 (로컬 dev)
## 3-AI
주 개발 환경은 **Windows 11 + PowerShell**. 아래는 PowerShell 기준 최소 명령이다.
(엔진 게이트웨이가 없어도 UI·로그인·페르소나·세션 생성(in-memory)·네비게이션은 동작하며,
실제 AI 턴 생성만 실패한다. DB가 없어도 인메모리 degraded로 기동된다.)
① 심리상담사 AI(선택) ② 가상 내담자 AI ③ 백그라운드 평가/교수 AI — 정보 비대칭을 DB `visible_to`가 강제.
### 2-1. API 백엔드
## 상태
```powershell
cd apps\api
# 로컬 dev 기본값 복사 (pydantic-settings가 apps/api/.env 를 자동 로드)
Copy-Item ..\..\.env.example .env
# (선택) seed 페르소나가 필요하면 기동 전에 환경변수 설정
$env:AUTO_SEED_PERSONAS = "true"; $env:ALLOW_SEED_PERSONA_FALLBACK = "true"
python -m uvicorn app.main:app --host 127.0.0.1 --port 8000 --reload
```
설계 완료, 구현 착수 단계 (Phase 0 기반정렬 → P1 텍스트 상담 MVP → P2 음성+3역할 → P3 파일럿).
- 기동 로그에 `Application startup complete` 가 뜨면 정상. DB 연결 실패 시 `store` 인메모리
degraded로 기동되고 `GET /health``{"status":"degraded","db":false,...}` 를 반환한다.
- dev 로그인 활성 조건: `.env``ENVIRONMENT=dev`, `AUTH_DEV_LOGIN_ENABLED=true`
(루트 `.env.example` 기본값에 이미 포함).
### 2-2. Web 프론트엔드
```powershell
cd apps\web
npm install
npm run dev # http://localhost:5173
```
- Vite dev 서버는 `/api` 요청을 `http://127.0.0.1:8000` 으로 프록시하고 `/api` 프리픽스를 제거한다
(예: `/api/auth/dev-login` → 백엔드 `/auth/dev-login`).
### 2-3. dev-login 으로 진입
웹 UI의 로그인 화면에서 dev-login 경로로 들어가거나, 직접 호출한다.
```powershell
# 웹 프록시 경유 (web dev 서버가 떠 있을 때)
curl -X POST http://localhost:5173/api/auth/dev-login `
-H "Content-Type: application/json" `
-d '{"email":"learner@hs.ac.kr","role":"learner","display_name":"테스트 학습자"}'
```
- 요청 바디: `email`(필수), `role`(`learner` | `teacher` | `admin`, 기본 `learner`), `display_name`(선택).
- 성공 시 `__Host-vignette_sid` 세션 쿠키가 발급된다.
### 2-4. (선택) AI 턴 생성용 엔진 게이트웨이
`ENGINE_MODE=claude_cli` 인 경우 호스트에서 게이트웨이를 9099 포트로 띄운다(claude CLI 사용).
```powershell
cd apps\api
uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
```
게이트웨이가 없으면 `GET /health``engine:false` 이고 턴 생성만 실패한다.
### 2-5. 테스트 / 검증
```powershell
# 백엔드
cd apps\api; python -m pytest app/ -q # 현재 약 77 pass
python -m pytest engine_gateway/ -q # 약 7 pass
# 프론트엔드
cd apps\web; npm run typecheck # tsc -b
npm run build # tsc -b && vite build
npm run e2e # Playwright (web + api + DB 스택 필요)
```
### 2-6. (선택) Docker Compose 전체 스택
```powershell
cd infra
Copy-Item .env.example .env # 실제 시크릿은 .env 에만
docker compose up -d # db(pgvector pg16) + api + web + rag + proxy(Caddy)
```
Docker Desktop이 필요하다.
---
## 3. 주요 문서
| 문서 | 용도 |
|---|---|
| **`docs/dev_dashboard.html`** | **SSOT(단일 진실 공급원)** — 상태·검증 증거·결정 필요·로드맵의 권위 기준 |
| `docs/ops/backlog-2026-06-26.md` | 운영 백로그(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스). 대시보드와 일치 |
| `docs/ops/source-docs-gap-analysis-2026-06-26.md` | 원천문서 갭 분석(대시보드 "원천문서 갭" 항목의 상세 근거) |
| `docs/guides/local-development.md` | 로컬 개발 환경 구축·실행 상세 가이드 |
| `docs/guides/architecture.md` | 시스템 아키텍처(엔진/오케스트레이터/저항/마스킹/음성/평가/데이터) 상세 |
| `docs/guides/testing.md` | 테스트·검증(pytest, typecheck, Playwright E2E 게이트) 가이드 |
| `CLAUDE.md` / `AGENT.md` | 작업·에이전트 운영 지침(OS 선파악, 증거 정직성, SSOT 동기화) |
참고 설계 문서: `docs/MASTERPLAN.md`(마스터플랜) · `docs/HANDOFF.md`(인수인계) ·
`docs/DEPLOYMENT.md`(배포) · `docs/DESIGN_CONCEPT.md`(디자인 컨셉) ·
`docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md`(메모리·지식·페르소나).
---
## 4. 핵심 운영 원칙
1. **OS를 먼저 파악하고 시작한다(최우선).** 주 환경은 Windows 11 + PowerShell.
POSIX를 가정하지 말고 경로·인코딩·도구 가용성을 먼저 확정한다(한글·공백 경로 주의,
파일 출력은 `-Encoding utf8`). 자세한 내용은 `CLAUDE.md` / `AGENT.md` 규칙 0.
2. **가짜 증거로 DONE 표기 금지.** 실증 불가/외부 의존/소유자 결정 항목은
`docs/ops/backlog-*.md` 에 분류·추적하고, 변경 후 검증(typecheck / pytest / E2E 게이트)을
실제로 돌려 결과를 그대로 보고한다.
3. **`docs/dev_dashboard.html` 이 SSOT.** 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고
대시보드에 반영·동기화한다. 백로그가 대시보드와 어긋나면 대시보드를 기준으로 맞춘다.
4. **소유자(윤찬) 단독 결정 사안은 임의로 정하지 않는다(월권 금지).**
5. 모든 소통·주석·커밋 메시지는 한글. 커밋 메시지에 Co-Authored-By / Claude 관련 문구 금지.

View file

@ -27,6 +27,19 @@ def _is_local_url(value: str) -> bool:
return host in {"localhost", "127.0.0.1", "::1"}
def _is_allowed_local_dev_cors_origin(value: str) -> bool:
parsed = urlsplit(value)
host = (parsed.hostname or "").lower()
return (
parsed.scheme == "http"
and host in {"localhost", "127.0.0.1"}
and parsed.port in range(5170, 5181)
and not parsed.path
and not parsed.query
and not parsed.fragment
)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
@ -106,6 +119,22 @@ class Settings(BaseSettings):
default=False,
validation_alias="AUTH_DEV_LOGIN_ENABLED",
)
auth_saml_enabled: bool = Field(
default=False,
validation_alias="AUTH_SAML_ENABLED",
)
saml_sp_entity_id: str = Field(
default="",
validation_alias="SAML_SP_ENTITY_ID",
)
saml_sso_url: str = Field(
default="",
validation_alias="SAML_SSO_URL",
)
saml_x509_cert_fingerprint: str = Field(
default="",
validation_alias="SAML_X509_CERT_FINGERPRINT",
)
default_affiliation: str = Field(
default="",
validation_alias="DEFAULT_AFFILIATION",
@ -160,11 +189,23 @@ class Settings(BaseSettings):
forbidden.append("SESSION_SECRET")
if _is_local_url(self.frontend_base_url):
forbidden.append("FRONTEND_BASE_URL")
if any(_is_local_url(origin) for origin in self.cors_origins):
if any(
_is_local_url(origin) and not _is_allowed_local_dev_cors_origin(origin)
for origin in self.cors_origins
):
forbidden.append("CORS_ORIGINS")
if forbidden:
joined = ", ".join(forbidden)
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
if self.auth_saml_enabled:
missing_saml: list[str] = []
if not self.saml_sp_entity_id.strip():
missing_saml.append("SAML_SP_ENTITY_ID")
if not self.saml_sso_url.strip():
missing_saml.append("SAML_SSO_URL")
if missing_saml:
joined = ", ".join(missing_saml)
raise ValueError(f"{joined} must be configured when AUTH_SAML_ENABLED=true")
return self

View file

@ -39,8 +39,6 @@ class GenerateRequest(BaseModel):
ai_role: AIRole
messages: list[EngineMessage]
# tier 라우팅 힌트: client=Sonnet/Solar, evaluator=Opus, fast=Haiku (마스터플랜 §5)
tier: Literal["client", "feedback", "fast"] = "client"
model: Optional[str] = None # 명시 시 게이트웨이 override
max_tokens: int = 1024
temperature: float = 0.7

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import json
import uuid
from dataclasses import dataclass
from typing import Any, Iterable
from typing import Any, Iterable, Literal, cast
from .config import settings
from .db import acquire, get_pool
@ -18,6 +18,7 @@ from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
SEED_VERSION = 1
PersonaStatus = Literal["draft", "review", "approved", "archived"]
_CARD_COLUMNS = """
persona_id, code, version, status, display_name, difficulty, theory_target,
@ -25,6 +26,15 @@ _CARD_COLUMNS = """
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic
"""
_REVIEW_COLUMNS = """
persona_id, code, version, status, display_name, difficulty, theory_target,
source_provenance, is_synthetic, created_at, approved_at
"""
_PERSONA_STATUSES = {"draft", "review", "approved", "archived"}
_REVIEW_QUEUE_STATUSES = ("draft", "review")
PersonaReviewAction = Literal["approve", "reject"]
@dataclass(frozen=True, slots=True)
class CatalogPersona:
@ -35,6 +45,21 @@ class CatalogPersona:
degraded: bool = False
@dataclass(frozen=True, slots=True)
class PersonaReviewItem:
persona_id: str
code: str
version: int
status: PersonaStatus
display_name: str
difficulty: str
theory_target: list[str]
source_provenance: str
is_synthetic: bool
created_at: str | None
approved_at: str | None
def seed_persona_id(code: str) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}"))
@ -54,6 +79,24 @@ def _string_list(value: Iterable[Any] | None) -> list[str]:
return [str(item) for item in value]
def _optional_text(value: Any) -> str | None:
if value is None:
return None
isoformat = getattr(value, "isoformat", None)
if callable(isoformat):
return str(isoformat())
return str(value)
def _normalize_statuses(statuses: Iterable[str]) -> list[str]:
normalized: list[str] = []
for status in statuses:
value = str(status).strip().lower()
if value in _PERSONA_STATUSES and value not in normalized:
normalized.append(value)
return normalized
def card_from_row(row: Any) -> PersonaCard:
return PersonaCard(
code=str(row["code"]).upper(),
@ -84,6 +127,22 @@ def catalog_persona_from_row(row: Any) -> CatalogPersona:
)
def persona_review_item_from_row(row: Any) -> PersonaReviewItem:
return PersonaReviewItem(
persona_id=str(row["persona_id"]),
code=str(row["code"]).upper(),
version=int(row["version"]),
status=cast(PersonaStatus, str(row["status"]).lower()),
display_name=str(row["display_name"]),
difficulty=str(row["difficulty"]),
theory_target=_string_list(row["theory_target"]),
source_provenance=str(row["source_provenance"] or ""),
is_synthetic=bool(row["is_synthetic"]),
created_at=_optional_text(row["created_at"]),
approved_at=_optional_text(row["approved_at"]),
)
def seed_fallback_persona(code: str) -> CatalogPersona | None:
card = get_seed_persona(code)
if card is None:
@ -209,6 +268,93 @@ async def get_approved_persona(code: str) -> CatalogPersona | None:
return catalog_persona_from_row(row) if row is not None else None
async def list_persona_review_queue(
*,
role: str,
statuses: Iterable[str] = _REVIEW_QUEUE_STATUSES,
) -> list[PersonaReviewItem]:
if role not in {"teacher", "admin"}:
raise ValueError("persona review queue requires teacher or admin role")
status_values = _normalize_statuses(statuses)
if not status_values:
return []
get_pool()
async with acquire(role=role) as conn:
rows = await conn.fetch(
f"""
SELECT {_REVIEW_COLUMNS}
FROM app.persona_card
WHERE status = ANY($1::text[])
ORDER BY
CASE status
WHEN 'review' THEN 0
WHEN 'draft' THEN 1
ELSE 2
END,
code,
version DESC
""",
status_values,
)
return [persona_review_item_from_row(row) for row in rows]
async def update_persona_review_status(
*,
persona_id: str,
action: PersonaReviewAction,
reviewer_id: str,
role: str,
) -> PersonaReviewItem | None:
if role not in {"teacher", "admin"}:
raise ValueError("persona review update requires teacher or admin role")
if action not in {"approve", "reject"}:
raise ValueError("unsupported persona review action")
next_status = "approved" if action == "approve" else "draft"
approved_by = reviewer_id if action == "approve" else None
approved_at_expr = "now()" if action == "approve" else "NULL"
get_pool()
async with acquire(role=role, user_id=reviewer_id) as conn:
row = await conn.fetchrow(
f"""
UPDATE app.persona_card
SET
status = $2,
approved_by = $3::uuid,
approved_at = {approved_at_expr}
WHERE persona_id = $1::uuid
AND status IN ('draft', 'review')
RETURNING {_REVIEW_COLUMNS}
""",
persona_id,
next_status,
approved_by,
)
if row is None:
return None
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
reviewer_id,
f"persona_{action}",
"persona_card",
persona_id,
{
"next_status": next_status,
"code": str(row["code"]).upper(),
"version": int(row["version"]),
},
)
return persona_review_item_from_row(row)
async def list_catalog_personas() -> list[CatalogPersona]:
try:
return await list_approved_personas()
@ -229,6 +375,9 @@ async def get_catalog_persona(code: str) -> CatalogPersona | None:
__all__ = [
"CatalogPersona",
"PersonaReviewItem",
"PersonaReviewAction",
"PersonaStatus",
"SEED_VERSION",
"card_from_row",
"catalog_persona_from_row",
@ -236,8 +385,11 @@ __all__ = [
"get_catalog_persona",
"list_approved_personas",
"list_catalog_personas",
"list_persona_review_queue",
"materialize_seed_personas",
"persona_review_item_from_row",
"seed_fallback_persona",
"seed_fallback_personas",
"seed_persona_id",
"update_persona_review_status",
]

View file

@ -25,6 +25,13 @@ from pydantic import BaseModel
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
from ..saml import (
SamlIdentity,
acs_url_for_entity_id,
build_authn_request,
parse_fixture_response,
redirect_binding_url,
)
router = APIRouter(prefix="/auth", tags=["auth"])
@ -41,7 +48,15 @@ class OAuthState:
created_at: float
@dataclass(slots=True)
class SamlState:
request_id: str
next_path: str
created_at: float
_oauth_states: dict[str, OAuthState] = {}
_saml_states: dict[str, SamlState] = {}
class MeResponse(BaseModel):
@ -52,8 +67,17 @@ class MeResponse(BaseModel):
cohort_ids: list[str]
class AuthProviderStatus(BaseModel):
provider: Literal["google", "saml"]
configured: bool
enabled: bool
login_path: str
class AuthConfigResponse(BaseModel):
google_oauth_configured: bool
saml_configured: bool
providers: list[AuthProviderStatus]
allowed_email_domains: list[str]
redirect_uri: str
dev_login_enabled: bool
@ -84,6 +108,37 @@ def _normalize_email_set(values: list[str]) -> set[str]:
return {email for value in values if (email := _normalize_email(value))}
def _google_configured() -> bool:
return bool(settings.oauth_google_client_id and settings.oauth_google_client_secret)
def _saml_configured() -> bool:
return bool(
settings.auth_saml_enabled
and settings.saml_sp_entity_id.strip()
and settings.saml_sso_url.strip()
)
def _auth_provider_statuses() -> list[AuthProviderStatus]:
google_ready = _google_configured()
saml_ready = _saml_configured()
return [
AuthProviderStatus(
provider="google",
configured=google_ready,
enabled=google_ready,
login_path="/auth/login?provider=google",
),
AuthProviderStatus(
provider="saml",
configured=saml_ready,
enabled=saml_ready,
login_path="/auth/login?provider=saml",
),
]
def allowed_email_domains() -> set[str]:
"""Configured login email domains, normalized for claim checks."""
return {
@ -137,6 +192,15 @@ def _role_for_email(email: str) -> Role:
return Role.LEARNER
def _role_for_saml_identity(identity: SamlIdentity) -> Role:
hinted = (identity.role_hint or "").strip().lower()
if hinted in {"admin", "administrator"}:
return Role.ADMIN
if hinted in {"teacher", "instructor", "faculty"}:
return Role.TEACHER
return _role_for_email(identity.email)
def _safe_next_path(next_path: str | None) -> str:
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
return "/"
@ -211,6 +275,13 @@ def _prune_oauth_states() -> None:
_oauth_states.pop(key, None)
def _prune_saml_states() -> None:
cutoff = time.time() - OAUTH_STATE_TTL_SECONDS
stale = [key for key, value in _saml_states.items() if value.created_at < cutoff]
for key in stale:
_saml_states.pop(key, None)
def _cookie_secure() -> bool:
# The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome
# accepts Secure cookies on localhost, which keeps dev and prod semantics
@ -291,10 +362,12 @@ def _dev_login_available(request: Request) -> bool:
@router.get("/config", response_model=AuthConfigResponse)
async def auth_config(request: Request) -> AuthConfigResponse:
"""Return non-secret login configuration for the browser login screen."""
google_ready = _google_configured()
saml_ready = _saml_configured()
return AuthConfigResponse(
google_oauth_configured=bool(
settings.oauth_google_client_id and settings.oauth_google_client_secret
),
google_oauth_configured=google_ready,
saml_configured=saml_ready,
providers=_auth_provider_statuses(),
allowed_email_domains=sorted(allowed_email_domains()),
redirect_uri=settings.oauth_redirect_uri,
dev_login_enabled=_dev_login_available(request),
@ -308,9 +381,33 @@ async def login(
next: Annotated[str | None, Query()] = None,
) -> RedirectResponse:
"""Start Google OIDC authorization code + PKCE login."""
if provider == "saml":
if not _saml_configured():
return _frontend_login_redirect("saml_not_configured", request)
_prune_saml_states()
relay_state = secrets.token_urlsafe(32)
acs_url = acs_url_for_entity_id(settings.saml_sp_entity_id)
request_id, authn_request_xml = build_authn_request(
sp_entity_id=settings.saml_sp_entity_id,
sso_url=settings.saml_sso_url,
acs_url=acs_url,
)
_saml_states[relay_state] = SamlState(
request_id=request_id,
next_path=_safe_next_path(next),
created_at=time.time(),
)
return RedirectResponse(
redirect_binding_url(
sso_url=settings.saml_sso_url,
authn_request_xml=authn_request_xml,
relay_state=relay_state,
),
status_code=302,
)
if provider != "google":
return _frontend_login_redirect("unsupported_provider", request)
if not settings.oauth_google_client_id or not settings.oauth_google_client_secret:
if not _google_configured():
return _frontend_login_redirect("not_configured", request)
_prune_oauth_states()
@ -406,6 +503,58 @@ async def callback(
return response
@router.post("/saml/acs")
async def saml_acs(request: Request) -> RedirectResponse:
"""Accept a minimal unsigned SAMLResponse for local fixture SAML proof.
Signed SAML verification is intentionally not implemented. When
SAML_X509_CERT_FINGERPRINT is configured, this endpoint refuses to trust the
response so production does not silently run unsigned SAML.
"""
if not _saml_configured():
return _frontend_login_redirect("saml_not_configured", request)
if settings.saml_x509_cert_fingerprint.strip():
return _frontend_login_redirect("saml_signature_verification_required", request)
if settings.environment != "dev":
return _frontend_login_redirect("saml_fixture_acs_dev_only", request)
form = await request.form()
relay_state = str(form.get("RelayState") or "")
encoded_response = str(form.get("SAMLResponse") or "")
if not relay_state or not encoded_response:
return _frontend_login_redirect("saml_missing_callback", request)
_prune_saml_states()
stored = _saml_states.pop(relay_state, None)
if stored is None:
return _frontend_login_redirect("saml_invalid_state", request)
try:
identity = parse_fixture_response(encoded_response)
email = validate_google_identity_domain(
email=identity.email,
email_verified=True,
hosted_domain=_email_domain(identity.email),
)
except (HTTPException, ValueError):
return _frontend_login_redirect("saml_assertion_invalid", request)
role = _role_for_saml_identity(identity)
try:
sid, _ = await create_session(
email=email,
display_name=identity.display_name or email,
role=role.value,
cohort_ids=[],
)
except InactiveUserError:
return _frontend_login_redirect("inactive_user", request)
response = RedirectResponse(_frontend_url(stored.next_path, request), status_code=302)
_set_session_cookie(response, sid)
return response
@router.post("/dev-login", response_model=MeResponse)
async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse:
"""Dev-only server login for local E2E and manual testing.

View file

@ -2,15 +2,23 @@
from __future__ import annotations
from typing import Any
from typing import Annotated, Any, Literal
from fastapi import APIRouter, HTTPException, Response, status
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel
from ..deps import CurrentPrincipal
from ..persona_repository import CatalogPersona, list_catalog_personas
from ..deps import CurrentPrincipal, Principal, Role, require_role
from ..persona_repository import (
CatalogPersona,
PersonaReviewAction,
PersonaReviewItem,
list_catalog_personas,
list_persona_review_queue,
update_persona_review_status,
)
router = APIRouter(prefix="/personas", tags=["personas"])
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
class PersonaSummary(BaseModel):
@ -25,6 +33,24 @@ class PersonaSummary(BaseModel):
degraded: bool = False
class PersonaReviewSummary(BaseModel):
persona_id: str
code: str
version: int
status: Literal["draft", "review", "approved", "archived"]
display_name: str
difficulty: str
theory_target: list[str]
source_provenance: str
is_synthetic: bool
created_at: str | None = None
approved_at: str | None = None
class PersonaReviewDecisionRequest(BaseModel):
action: PersonaReviewAction
def _first_text_value(data: dict[str, Any]) -> str:
for value in data.values():
if isinstance(value, str) and value.strip():
@ -46,6 +72,27 @@ def _summary(entry: CatalogPersona) -> PersonaSummary:
)
def _review_summary(entry: PersonaReviewItem) -> PersonaReviewSummary:
return PersonaReviewSummary(
persona_id=entry.persona_id,
code=entry.code,
version=entry.version,
status=entry.status,
display_name=entry.display_name,
difficulty=entry.difficulty,
theory_target=entry.theory_target,
source_provenance=entry.source_provenance,
is_synthetic=entry.is_synthetic,
created_at=entry.created_at,
approved_at=entry.approved_at,
)
def _ensure_teacher_or_admin(principal: Principal) -> None:
if principal.role not in {Role.TEACHER, Role.ADMIN}:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only teachers and admins can review personas")
@router.get("", response_model=list[PersonaSummary])
async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]:
"""Return latest approved personas from app.persona_card."""
@ -64,3 +111,47 @@ async def list_personas(response: Response, _principal: CurrentPrincipal) -> lis
response.headers["X-Vignette-Catalog-Source"] = "database"
return [_summary(entry) for entry in personas]
@router.get("/review", response_model=list[PersonaReviewSummary])
async def list_persona_reviews(principal: TeacherOrAdmin) -> list[PersonaReviewSummary]:
"""Return draft/review personas awaiting faculty approval."""
_ensure_teacher_or_admin(principal)
try:
queue = await list_persona_review_queue(role=principal.role.value)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona review queue database unavailable",
) from exc
return [_review_summary(entry) for entry in queue]
@router.post("/review/{persona_id}", response_model=PersonaReviewSummary)
async def decide_persona_review(
persona_id: str,
request: PersonaReviewDecisionRequest,
principal: TeacherOrAdmin,
) -> PersonaReviewSummary:
"""Approve a persona for learners or return it to draft for changes."""
_ensure_teacher_or_admin(principal)
try:
updated = await update_persona_review_status(
persona_id=persona_id,
action=request.action,
reviewer_id=principal.user_id,
role=principal.role.value,
)
except ValueError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona review update database unavailable",
) from exc
if updated is None:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail="persona review item not found or not pending review",
)
return _review_summary(updated)

View file

@ -18,13 +18,13 @@ from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel, Field
from sse_starlette.sse import EventSourceResponse
from .. import session_persistence
from .. import db, session_persistence
from ..config import settings
from ..deps import CurrentPrincipal, Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from ..services import evaluator, memory, orchestrator, state_machine
from ..services import evaluator, memory, orchestrator, rag, state_machine
from ..store import InProcSession, TurnRecord, store
router = APIRouter(prefix="/sessions", tags=["sessions"])
@ -193,6 +193,165 @@ class SessionReviewResponse(BaseModel):
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
# 세션별 KB 증상 행동단서(회기 1회 산출·캐시). 빈 list 캐시 = 회기 내 재시도 안 함(안정성).
_KB_CUES_CACHE: dict[str, list[str]] = {}
_LEARNER_VISIBLE_AI_ROLE = "counselor"
# ────────────────────────────────────────────────────────────────────────────
# RAG 배선 헬퍼 — 내담자(CLIENT) 뷰. 임베더/KB/DB 풀 미가용 시 빈 값으로 graceful
# degradation: 상담 루프를 절대 막지 않는다(라이브 루프 비차단이 계약). routes/kb.py가
# 같은 예외를 503으로 올리는 것과 의도적으로 다르다. 임베딩은 rag가 스레드풀로 offload.
# ────────────────────────────────────────────────────────────────────────────
_RAG_RECALL_K = 5
_KB_CUES_K = 4
def _persona_kb_query(card) -> str:
"""페르소나 증상·호소 → KB 행동단서 검색 질의(임베더/tsquery 입력 전용, LLM 미주입).
질의는 프롬프트에 들어가지 않는다. 회수된 behavior_cue만 L2로 주입되고, CLIENT 정책
(expose_body=False) 본문을 잘라 '행동단서' 돌려준다(CCD 본문 비노출 자동 보존).
"""
parts: list[str] = []
presenting = getattr(card, "presenting", None) or {}
if presenting.get("주호소"):
parts.append(str(presenting["주호소"]))
if presenting.get("표층"):
parts.append(str(presenting["표층"]))
dsm = getattr(card, "dsm5_dimensional", None) or {}
parts.extend(str(key) for key in dsm.keys() if key != "note")
return " ".join(p for p in parts if p).strip()
async def _retrieve_kb_behavior_cues(card) -> list[str]:
"""KB 증상 행동단서 회수(CLIENT 정책). 미가용 시 빈 리스트(비차단)."""
query = _persona_kb_query(card)
if not query:
return []
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
result = await rag.search_kb(
conn,
query=query,
role=rag.AIRole.CLIENT,
k=_KB_CUES_K,
)
return [c.behavior_cue for c in result.chunks if c.behavior_cue]
except Exception:
# rag.NotConfigured(임베더/KB 미가용)·RuntimeError(풀 미초기화)·DB 오류 포함.
# 비치명적: 빈 단서로 진행. CancelledError는 BaseException이라 미포착.
return []
async def _ensure_kb_cues(session_id: str, card) -> list[str]:
"""세션별 KB 행동단서(회기 1회 산출·캐시, 서버 재시작/재개 시 lazy 재계산)."""
cached = _KB_CUES_CACHE.get(session_id)
if cached is not None:
return cached
cues = await _retrieve_kb_behavior_cues(card)
_KB_CUES_CACHE[session_id] = cues
return cues
async def _load_prev_case_summary(case_id: str) -> Optional[dict]:
"""직전 회기 요약(case 스코프) → build_recall_context 입력. 미존재/미가용 시 None."""
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
row = await conn.fetchrow(
"""
SELECT digest, open_threads, end_state
FROM app.session_summary
WHERE case_id = $1::uuid
ORDER BY session_no DESC, created_at DESC
LIMIT 1
""",
case_id,
)
except Exception:
return None
if row is None:
return None
return {
"digest": row["digest"],
"open_threads": list(row["open_threads"] or []),
"end_state": dict(row["end_state"] or {}),
}
async def _hydrate_episodic_text(conn, result) -> list[str]:
"""retrieve_persona_memory가 돌려준 turn_id → app.turns 마스킹 본문 조인(내담자 발화)."""
turn_ids = [c.meta.get("turn_id") for c in result.chunks if c.meta.get("turn_id")]
if not turn_ids:
return []
rows = await conn.fetch(
"""
SELECT id, text_masked FROM app.turns
WHERE id = ANY($1::uuid[]) AND speaker = 'client'
""",
turn_ids,
)
by_id = {str(r["id"]): r["text_masked"] for r in rows}
return [by_id[t] for t in turn_ids if by_id.get(t)]
def _recall_query(prev_summary: Optional[dict], card) -> str:
"""episodic recall 질의: 직전 open_threads 우선, 없으면 주호소."""
if prev_summary:
threads = prev_summary.get("open_threads") or []
if threads:
return " ".join(str(t) for t in threads)
presenting = getattr(card, "presenting", None) or {}
return str(presenting.get("주호소") or "").strip()
async def _episodic_recall_snippets(case_id: str, query: str) -> list[str]:
"""case 스코프 episodic 벡터 recall → 내담자 발화 단편(마스킹본). 미가용 시 []."""
if not query:
return []
try:
async with db.acquire(ai_view=rag.AIRole.CLIENT.value) as conn:
result = await rag.retrieve_persona_memory(
conn, case_id=case_id, query=query, k=_RAG_RECALL_K,
)
return await _hydrate_episodic_text(conn, result)
except Exception:
return []
async def _build_start_recall(*, case_id: str, card) -> memory.RecallContext:
"""회기 시작 회상 조립: prev_summary(case) + episodic recall을 build_recall_context로
합본. 구간 graceful(미가용 회상).
"""
try:
db.get_pool() # 풀 미초기화 시 RuntimeError → 첫 회기와 동일한 빈 회상
except RuntimeError:
return memory.build_recall_context()
prev_summary = await _load_prev_case_summary(case_id)
query = _recall_query(prev_summary, card)
episodic = await _episodic_recall_snippets(case_id, query)
pinned = list((prev_summary or {}).get("pinned_facts") or [])
return memory.build_recall_context(
prev_summary=prev_summary,
episodic_snippets=episodic,
pinned_facts=pinned,
)
async def _warm_rag_caches(session_id: str, case_id: str, card) -> None:
"""RAG 회상·KB 행동단서를 **백그라운드**로 산출해 캐시한다(요청 경로 비차단).
BGE-M3 임베더 로드(~ ) 회기 시작/ 응답을 막지 않도록 create_task로 띄운다.
warm 완료 턴은 회상/단서로 진행(graceful), 이후 턴부터 RAG 주입. 구간 비치명적.
"""
try:
_RECALL_CACHE[session_id] = await _build_start_recall(case_id=case_id, card=card)
except Exception:
pass
try:
_KB_CUES_CACHE[session_id] = await _retrieve_kb_behavior_cues(card)
except Exception:
pass
_PHASE_KEY_BY_LABEL = {
"라포": "rapport",
@ -481,6 +640,92 @@ def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
return payload if isinstance(payload, dict) else {}
# fast-loop 턴 평가(TechniqueCategory) → 프론트 sr-technique--{kind} 시각 매핑.
_TECHNIQUE_KIND_BY_CATEGORY = {
"relational": "empathy",
"exploratory": "explore",
"intervention": "confront",
"stabilizing": "reflect",
"structuring": "closed",
}
def _review_techniques_from_turn_eval(ev: dict[str, object] | None) -> list[ReviewTechnique]:
"""턴 평가의 기법 태그를 리뷰 칩으로. label_ko 우선, category로 색 kind 결정."""
if not isinstance(ev, dict):
return []
out: list[ReviewTechnique] = []
for tag in ev.get("techniques") or []:
if not isinstance(tag, dict):
continue
label = str(tag.get("label_ko") or tag.get("code") or "").strip()
if not label:
continue
kind = _TECHNIQUE_KIND_BY_CATEGORY.get(str(tag.get("category") or ""), "explore")
out.append(ReviewTechnique(kind=kind, label=label))
return out
def _review_note_from_turn_eval(ev: dict[str, object] | None) -> Optional[ReviewNote]:
"""의도이탈(있으면 우선) 또는 적절성 신호를 턴 노트로. tone: good|warn(프론트 계약)."""
if not isinstance(ev, dict):
return None
dev = ev.get("intent_deviation")
if isinstance(dev, dict):
dimension = str(dev.get("dimension") or "").strip()
expected = str(dev.get("expected") or "").strip()
actual = str(dev.get("actual") or "").strip()
body = " / ".join(p for p in (f"권장: {expected}" if expected else "", f"실제: {actual}" if actual else "") if p)
return ReviewNote(
author="평가 AI",
tone="warn",
title=f"의도와 다른 부분 · {dimension}".rstrip(" ·") or "의도와 다른 부분",
body=body or "권장 반응과 실제 반응에 차이가 있었어요.",
)
appropriateness = str(ev.get("appropriateness") or "neutral")
note_text = str(ev.get("appropriateness_note") or "").strip()
if appropriateness == "pos":
return ReviewNote(author="평가 AI", tone="good", title="적절한 개입", body=note_text or "이 개입은 흐름에 적절했어요.")
if appropriateness == "warn" and note_text:
return ReviewNote(author="평가 AI", tone="warn", title="점검해볼 지점", body=note_text)
return None
async def _record_safety_event(sess: InProcSession, ctx, result) -> None:
"""위기 escalate 시 app.safety_events 적재(교수자 감사·알림 레코드). C2.
비차단: DB 미가용(degraded)·FK 미충족(in-memory 세션) graceful skip 상담 루프를
절대 막지 않는다. 실시간 교수자 push 알림은 후속( 레코드가 1 알림원).
"""
crisis = getattr(ctx, "crisis", None)
if crisis is None or not getattr(crisis, "escalate", False):
return
kind = getattr(crisis.kind, "value", None) or str(getattr(crisis, "kind", "crisis"))
try:
async with db.acquire() as conn:
await conn.execute(
"""
INSERT INTO app.safety_events
(session_id, trigger_type, ko_risk_level, escalated, detail)
VALUES ($1::uuid, $2, $3, TRUE, $4::jsonb)
""",
sess.session_id,
kind,
int(getattr(crisis, "risk_level", 0) or 0),
json.dumps({
"matched": list(getattr(crisis, "matched", []) or []),
"stage": getattr(result, "stage", None),
"turn_seq": getattr(result, "turn_seq", None),
}),
)
except Exception:
pass # 비차단(R5): 적재 실패가 위기 대응/상담을 막지 않음.
def _learner_visible_turns(sess: InProcSession) -> list[TurnRecord]:
return sess.turns_visible_to(_LEARNER_VISIBLE_AI_ROLE)
async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
if not sess.turns:
return
@ -535,8 +780,9 @@ def _schedule_session_evaluation(sess: InProcSession) -> None:
def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary:
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
turns = _learner_visible_turns(sess)
learner_turns = sum(1 for turn in turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in turns if turn.speaker == "client")
return LearnerSessionSummary(
session_id=sess.session_id,
persona_code=sess.persona_code,
@ -544,7 +790,7 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear
session_no=sess.session_no,
status="ended" if sess.ended else "active",
stage=_stage_label(sess.state.stage),
turn_count=len(sess.turns),
turn_count=len(turns),
learner_turn_count=learner_turns,
client_turn_count=client_turns,
started_at=_iso(sess.created_at) or "",
@ -554,7 +800,10 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear
async def _review_ready(sess: InProcSession, principal: Principal) -> bool:
if not sess.ended or not sess.turns:
turns = _learner_visible_turns(sess)
if not sess.ended or not turns:
return False
if len(turns) != len(sess.turns):
return False
evaluation_record, _ = await session_persistence.load_session_evaluation(
sess.session_id,
@ -568,6 +817,7 @@ def _session_detail(
*,
review_ready: bool = False,
) -> SessionDetailResponse:
turns = _learner_visible_turns(sess)
return SessionDetailResponse(
session_id=sess.session_id,
case_id=sess.case_id,
@ -587,7 +837,7 @@ def _session_detail(
text=turn.text_masked,
created_at=_iso(turn.created_at) or "",
)
for turn in sess.turns
for turn in turns
],
review_ready=review_ready,
)
@ -649,10 +899,7 @@ async def start_session(
recall = memory.build_recall_context()
st = state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
params=card.openness_params(),
carry=recall.carry,
)
@ -680,7 +927,11 @@ async def start_session(
)
else:
store.put(sess)
# 즉시 빈/carry 회상으로 응답을 막지 않는다. RAG 회상·KB 단서(임베더 로드 수 초)는
# 백그라운드 warm으로 캐시 — 회기 시작/턴 응답이 임베더 로드에 블로킹되지 않게(성능 회귀 방지).
_RECALL_CACHE[sess.session_id] = recall
asyncio.create_task(_warm_rag_caches(sess.session_id, sess.case_id, card))
return SessionStartResponse(
session_id=sess.session_id,
@ -701,6 +952,8 @@ async def get_session_review(
"""Return a learner-safe review built only from the stored session transcript."""
_ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
visible_turns = _learner_visible_turns(sess)
hidden_turns = len(visible_turns) != len(sess.turns)
end_ts = sess.ended_at or datetime.now().timestamp()
duration_seconds = max(0, int(round(end_ts - sess.created_at)))
@ -708,7 +961,7 @@ async def get_session_review(
client_initial = client_name[:1] or ""
reached_phase = _stage_label(sess.state.stage)
stage_labels = [turn.stage for turn in sess.turns] or [reached_phase]
stage_labels = [turn.stage for turn in visible_turns] or [reached_phase]
axis = ["0:00"]
if duration_seconds > 0:
axis.append(_offset_label(duration_seconds))
@ -717,16 +970,20 @@ async def get_session_review(
session_id,
principal,
)
evaluation_payload = _evaluation_payload(evaluation_record)
evaluation_status = str(evaluation_record.get("status") or "") if evaluation_record else ""
evaluation_ready = evaluation_status == "ready"
evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record)
evaluation_status = (
"" if hidden_turns else str(evaluation_record.get("status") or "") if evaluation_record else ""
)
evaluation_ready = not hidden_turns and evaluation_status == "ready"
first_turn_ts = sess.turns[0].created_at if sess.turns else sess.created_at
first_turn_ts = visible_turns[0].created_at if visible_turns else sess.created_at
turns: list[ReviewTurn] = []
for index, turn in enumerate(sess.turns):
for index, turn in enumerate(visible_turns):
speaker: Literal["learner", "client"] = (
"learner" if turn.speaker == "counselor" else "client"
)
# 턴별 fast-loop 평가는 학습자 발화에만 부착(기법 태깅·노트). hidden 시 노출 안 함.
turn_eval = turn.evaluation if (speaker == "learner" and not hidden_turns) else None
turns.append(
ReviewTurn(
id=f"t{index + 1}",
@ -734,8 +991,8 @@ async def get_session_review(
speaker=speaker,
who="학습자" if speaker == "learner" else client_name,
text=turn.text_masked,
techniques=[],
note=None,
techniques=_review_techniques_from_turn_eval(turn_eval),
note=_review_note_from_turn_eval(turn_eval),
)
)
@ -783,10 +1040,10 @@ async def get_session_review(
summary = _review_summary_from_evaluation(
fallback=transcript_summary,
evaluation_record=evaluation_record,
evaluation_record=None if hidden_turns else evaluation_record,
payload=evaluation_payload,
)
if evaluation_record and not evaluation_durable:
if evaluation_record and not hidden_turns and not evaluation_durable:
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
return SessionReviewResponse(
@ -832,6 +1089,7 @@ async def submit_turn(
_ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
session_id=session_id,
@ -841,18 +1099,25 @@ async def submit_turn(
learner_text=body.text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
theory_mode=sess.theory_mode,
)
assert ctx.state_after is not None
try:
result = await orchestrator.run_turn_generate(ctx, engine_client)
result = await orchestrator.run_turn_generate(
ctx,
engine_client,
eval_hook=evaluator.make_eval_hook(engine_client),
)
except EngineError as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"engine unavailable: {exc}",
) from exc
# 턴별 fast-loop 평가는 학습자(상담자) 발화에 부착(기법 태깅·적절성·의도이탈).
await _append_session_turn(
sess,
TurnRecord(
@ -861,6 +1126,7 @@ async def submit_turn(
stage=_stage_label(ctx.state_after.stage),
text=body.text,
text_masked=ctx.learner_text_masked,
evaluation=result.evaluation,
),
)
@ -873,9 +1139,15 @@ async def submit_turn(
stage=_stage_label(result.state_after.stage),
text=result.client_reply,
text_masked=result.client_reply,
llm_provider=result.llm_provider,
model=result.model,
tokens_in=result.tokens_in,
tokens_out=result.tokens_out,
cost_usd=result.cost_usd,
),
)
await _update_session_state(sess, result.state_after)
await _record_safety_event(sess, ctx, result) # C2: 위기 escalate 시 safety_events 적재(비차단)
return TurnResponse(
turn_seq=result.turn_seq,
@ -897,6 +1169,7 @@ async def stream_turn(
_ensure_learner(principal)
sess = await _load_session_or_404(session_id, principal)
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
ctx = orchestrator.prepare_turn(
session_id=session_id,
@ -906,7 +1179,9 @@ async def stream_turn(
learner_text=body.text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
recent_turns=sess.recent_turns(visible_to="client"),
kb_behavior_cues=kb_cues,
theory_mode=sess.theory_mode,
)
assert ctx.state_after is not None
@ -941,6 +1216,11 @@ async def stream_turn(
stage=_stage_label(ctx.state_after.stage),
text=final_reply,
text_masked=final_reply,
llm_provider=str(ev.data.get("llm_provider") or ""),
model=str(ev.data.get("model") or ""),
tokens_in=int(ev.data.get("tokens_in") or 0),
tokens_out=int(ev.data.get("tokens_out") or 0),
cost_usd=float(ev.data.get("cost_usd") or 0.0),
),
)
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
@ -980,6 +1260,7 @@ async def end_session(
await _end_persisted_session(sess, carry)
_RECALL_CACHE.pop(session_id, None)
_KB_CUES_CACHE.pop(session_id, None)
_schedule_session_evaluation(sess)
return SessionEndResponse(

View file

@ -14,6 +14,8 @@ cleanly instead of crashing.
from __future__ import annotations
import json
import hashlib
import time
from typing import Optional
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
@ -27,7 +29,7 @@ from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from ..services import memory, orchestrator, state_machine
from ..services import evaluator, memory, orchestrator, state_machine
from ..services import voice as voice_svc
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
from ..store import InProcSession, TurnRecord, store
@ -113,6 +115,8 @@ async def voice_ws(websocket: WebSocket) -> None:
audio_buf = bytearray()
receiving = False
audio_started_at: float | None = None
last_audio_end_at: float | None = None
try:
while True:
@ -126,6 +130,7 @@ async def voice_ws(websocket: WebSocket) -> None:
if not receiving:
# Be tolerant when audio arrives before audio_start.
receiving = True
audio_started_at = time.monotonic()
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
audio_buf.extend(msg["bytes"])
@ -151,11 +156,16 @@ async def voice_ws(websocket: WebSocket) -> None:
ctype = ctrl.get("type")
if ctype == "audio_start":
receiving = True
audio_started_at = time.monotonic()
audio_buf.clear()
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
elif ctype == "audio_end":
receiving = False
audio_ended_at = time.monotonic()
silence_ms = _safe_int(ctrl.get("silence_ms"))
if silence_ms is None and last_audio_end_at is not None and audio_started_at is not None:
silence_ms = max(0, int((audio_started_at - last_audio_end_at) * 1000))
await _handle_utterance(
websocket,
session_id=session_id,
@ -163,7 +173,13 @@ async def voice_ws(websocket: WebSocket) -> None:
voice_preset=voice_preset,
audio=bytes(audio_buf),
fmt=ctrl.get("format"),
audio_started_at=audio_started_at,
audio_ended_at=audio_ended_at,
silence_ms=silence_ms,
barge_in=_safe_bool(ctrl.get("barge_in")),
)
last_audio_end_at = audio_ended_at
audio_started_at = None
audio_buf.clear()
elif ctype == "text_turn":
@ -202,6 +218,10 @@ async def _handle_utterance(
voice_preset: VoicePreset,
audio: bytes,
fmt: Optional[str],
audio_started_at: float | None = None,
audio_ended_at: float | None = None,
silence_ms: int | None = None,
barge_in: bool | None = None,
) -> None:
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
if not audio:
@ -226,6 +246,9 @@ async def _handle_utterance(
return
learner_text = stt.text
audio_ref = _voice_audio_ref(audio, fmt)
duration_s = stt.duration or _elapsed_seconds(audio_started_at, audio_ended_at)
speech_rate = _estimate_speech_rate(learner_text, duration_s)
await _safe_send_json(
websocket,
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
@ -240,6 +263,10 @@ async def _handle_utterance(
principal=principal,
voice_preset=voice_preset,
learner_text=learner_text,
audio_ref=audio_ref,
silence_ms=silence_ms,
speech_rate=speech_rate,
barge_in=barge_in,
)
@ -250,6 +277,10 @@ async def _run_turn_and_speak(
principal: Principal,
voice_preset: VoicePreset,
learner_text: str,
audio_ref: str | None = None,
silence_ms: int | None = None,
speech_rate: float | None = None,
barge_in: bool | None = None,
) -> None:
"""Run one counseling turn and stream synthesized client speech."""
sess, err = await _load_voice_session(session_id, principal)
@ -267,13 +298,18 @@ async def _run_turn_and_speak(
learner_text=learner_text,
recall_summary=recall.recall_summary,
pinned_facts=recall.pinned_facts,
recent_turns=sess.recent_turns(),
recent_turns=sess.recent_turns(visible_to="client"),
theory_mode=sess.theory_mode,
)
assert ctx.state_after is not None
# Voice needs the full client reply before TTS starts.
try:
result = await orchestrator.run_turn_generate(ctx, engine_client)
result = await orchestrator.run_turn_generate(
ctx,
engine_client,
eval_hook=evaluator.make_eval_hook(engine_client),
)
except EngineError as e:
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
@ -290,6 +326,11 @@ async def _run_turn_and_speak(
stage=ctx.state_after.stage.value,
text=learner_text,
text_masked=ctx.learner_text_masked,
audio_ref=audio_ref,
silence_ms=silence_ms,
speech_rate=speech_rate,
barge_in=barge_in,
evaluation=result.evaluation,
),
)
if reply:
@ -302,6 +343,11 @@ async def _run_turn_and_speak(
stage=result.stage,
text=reply,
text_masked=reply,
llm_provider=result.llm_provider,
model=result.model,
tokens_in=result.tokens_in,
tokens_out=result.tokens_out,
cost_usd=result.cost_usd,
),
)
await _update_voice_state(sess, result.state_after)
@ -333,10 +379,7 @@ async def _run_turn_and_speak(
try:
n = 0
async for ck in voice_service.synthesize_stream(reply, voice_preset):
# Metadata precedes the binary chunk so the client can pair them.
await _safe_send_json(
websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)}
)
# 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출).
await _safe_send_bytes(websocket, ck.audio)
n += 1
await _safe_send_json(websocket, {"type": "tts_end", "chunks": n})
@ -451,10 +494,7 @@ async def _bind_session(
card = catalog_persona.card
st = state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
params=card.openness_params(),
)
sess = await session_persistence.create_session(
learner_id=principal.user_id,
@ -511,6 +551,52 @@ def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
return table.get(f, ("audio.webm", "audio/webm"))
def _voice_audio_ref(audio: bytes, fmt: Optional[str]) -> str | None:
if not audio:
return None
f = (fmt or "webm").lower().lstrip(".") or "webm"
digest = hashlib.sha256(audio).hexdigest()[:24]
return f"voice:{f}:sha256:{digest}"
def _elapsed_seconds(started_at: float | None, ended_at: float | None) -> float | None:
if started_at is None or ended_at is None:
return None
return max(0.001, ended_at - started_at)
def _estimate_speech_rate(text: str, duration_s: float | None) -> float | None:
if not text or not duration_s or duration_s <= 0:
return None
units = sum(1 for ch in text if not ch.isspace())
if units <= 0:
return None
return round((units / duration_s) * 60.0, 2)
def _safe_int(value: object) -> int | None:
if value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
def _safe_bool(value: object) -> bool | None:
if value is None:
return None
if isinstance(value, bool):
return value
if isinstance(value, str):
normalized = value.strip().lower()
if normalized in {"1", "true", "yes", "y"}:
return True
if normalized in {"0", "false", "no", "n"}:
return False
return bool(value)
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
if websocket.client_state != WebSocketState.CONNECTED:
return

157
apps/api/app/saml.py Normal file
View file

@ -0,0 +1,157 @@
"""Minimal SAML SP helpers for local fixture authentication tests.
This module intentionally implements only the Redirect-binding AuthnRequest and
unsigned fixture ACS parsing needed for backend proof. Signed production SAML
assertion verification is not implemented here.
"""
from __future__ import annotations
import base64
import html
import uuid
import zlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Iterable
from urllib.parse import urlsplit, urlunsplit, urlencode
from xml.etree import ElementTree
SAML_PROTOCOL_NS = "urn:oasis:names:tc:SAML:2.0:protocol"
SAML_ASSERTION_NS = "urn:oasis:names:tc:SAML:2.0:assertion"
SAML_ATTRIBUTE_ROLE_NAMES = {
"role",
"roles",
"groups",
"memberOf",
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role",
}
SAML_ATTRIBUTE_EMAIL_NAMES = {
"email",
"mail",
"emailaddress",
"EmailAddress",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress",
}
SAML_ATTRIBUTE_DISPLAY_NAME_NAMES = {
"display_name",
"displayName",
"name",
"cn",
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name",
}
@dataclass(frozen=True, slots=True)
class SamlIdentity:
email: str
display_name: str
role_hint: str | None = None
def acs_url_for_entity_id(entity_id: str) -> str:
parsed = urlsplit(entity_id.strip())
if parsed.scheme and parsed.netloc:
path = parsed.path.rstrip("/")
if path.endswith("/metadata"):
path = path[: -len("/metadata")]
return urlunsplit((parsed.scheme, parsed.netloc, f"{path}/acs", "", ""))
value = entity_id.strip().rstrip("/")
if value.endswith("/metadata"):
value = value[: -len("/metadata")]
return value + "/acs"
def build_authn_request(
*,
sp_entity_id: str,
sso_url: str,
acs_url: str,
) -> tuple[str, str]:
request_id = "_" + uuid.uuid4().hex
issued_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
xml = (
f'<samlp:AuthnRequest xmlns:samlp="{SAML_PROTOCOL_NS}" '
f'xmlns:saml="{SAML_ASSERTION_NS}" ID="{request_id}" Version="2.0" '
f'IssueInstant="{issued_at}" Destination="{html.escape(sso_url, quote=True)}" '
f'AssertionConsumerServiceURL="{html.escape(acs_url, quote=True)}" '
f'ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">'
f"<saml:Issuer>{html.escape(sp_entity_id)}</saml:Issuer>"
"</samlp:AuthnRequest>"
)
return request_id, xml
def redirect_binding_url(*, sso_url: str, authn_request_xml: str, relay_state: str) -> str:
compressor = zlib.compressobj(wbits=-15)
deflated = compressor.compress(authn_request_xml.encode("utf-8")) + compressor.flush()
params = urlencode(
{
"SAMLRequest": base64.b64encode(deflated).decode("ascii"),
"RelayState": relay_state,
}
)
separator = "&" if "?" in sso_url else "?"
return f"{sso_url}{separator}{params}"
def inflate_redirect_request(encoded_request: str) -> str:
payload = base64.b64decode(encoded_request)
return zlib.decompress(payload, wbits=-15).decode("utf-8")
def parse_fixture_response(encoded_response: str) -> SamlIdentity:
try:
xml = base64.b64decode(encoded_response).decode("utf-8")
root = ElementTree.fromstring(xml)
except Exception as exc:
raise ValueError("invalid SAMLResponse") from exc
name_id = _first_text(root, f".//{{{SAML_ASSERTION_NS}}}NameID")
attributes = _attributes(root)
email = _first_attribute(attributes, SAML_ATTRIBUTE_EMAIL_NAMES) or name_id
if not email:
raise ValueError("email claim is required")
display_name = (
_first_attribute(attributes, SAML_ATTRIBUTE_DISPLAY_NAME_NAMES)
or name_id
or email
)
role_hint = _first_attribute(attributes, SAML_ATTRIBUTE_ROLE_NAMES)
return SamlIdentity(email=email, display_name=display_name or email, role_hint=role_hint)
def _first_text(root: ElementTree.Element, selector: str) -> str:
node = root.find(selector)
return (node.text or "").strip() if node is not None else ""
def _attributes(root: ElementTree.Element) -> dict[str, list[str]]:
values: dict[str, list[str]] = {}
for attribute in root.findall(f".//{{{SAML_ASSERTION_NS}}}Attribute"):
name = (attribute.attrib.get("Name") or "").strip()
if not name:
continue
collected: list[str] = []
for value in attribute.findall(f".//{{{SAML_ASSERTION_NS}}}AttributeValue"):
text = (value.text or "").strip()
if text:
collected.append(text)
if collected:
values[name] = collected
return values
def _first_attribute(attributes: dict[str, list[str]], names: Iterable[str]) -> str:
for name in names:
values = attributes.get(name)
if values:
return values[0]
lowered = {key.lower(): value for key, value in attributes.items()}
for name in names:
values = lowered.get(name.lower())
if values:
return values[0]
return ""

View file

@ -365,7 +365,10 @@ def _fewshot_block() -> str:
def _theory_mode(ctx: "TurnContext") -> Optional[str]:
"""페르소나 theory_target 에서 이론 모드 힌트(이론부합 평가용). 없으면 None."""
"""이론 모드(이론부합 평가용): 학습자 선택(회기 theory_mode) 우선, 없으면 페르소나 theory_target."""
sess_theory = getattr(ctx, "theory_mode", None)
if sess_theory:
return str(sess_theory)
tt = getattr(ctx.persona, "theory_target", None)
if isinstance(tt, (list, tuple)) and tt:
return ", ".join(str(x) for x in tt)
@ -634,7 +637,6 @@ async def evaluate_turn(
try:
req = GenerateRequest(
ai_role="evaluator",
tier="feedback",
messages=build_fast_messages(ctx, client_reply),
structured_schema=_fast_schema(),
max_tokens=900,
@ -691,7 +693,6 @@ async def evaluate_session(
try:
req = GenerateRequest(
ai_role="evaluator",
tier="feedback",
messages=build_deep_messages(
stage=stage,
scope=scope,

View file

@ -41,6 +41,13 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
("NUMID", re.compile(r"\b\d{12,}\b")),
# 구체적 날짜(생년월일 등): 2001.4.18 / 2001-04-18 / 2001년 4월 18일
("DATE", re.compile(r"(?:19|20)\d{2}\s?[.\-/년]\s?\d{1,2}\s?[.\-/월]\s?\d{1,2}\s?일?")),
# 금액(원): 1,200원 / 1200원 (3자리+ 또는 콤마구분) — 식별 맥락 보호
("MONEY", re.compile(r"\d{1,3}(?:,\d{3})+\s?원|\d{3,}\s?원")),
# 한국 주소 단편: ○○시/도 ○○시/군/구 ○○동/읍/면/로/길 (행정구역 연쇄)
("ADDR", re.compile(r"[가-힣]{2,}(?:시|도)\s?[가-힣]{1,4}(?:시|군|구)\s?[가-힣0-9]{1,}(?:동|읍|면|로|길)")),
# TODO(NER): 한국어 이름/기관명은 Presidio ko 모델/NER 필요(정규식 false-positive 위험).
]
# Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨)

View file

@ -37,8 +37,6 @@ from .state_machine import SessionState, Stage
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
# 로깅 훅: TurnContext + 내담자응답 → None (turns insert/임베딩은 주입측 책임)
LogHook = Callable[["TurnContext", str], Awaitable[None]]
@dataclass(slots=True)
@ -59,6 +57,8 @@ class TurnContext:
pinned_facts: list[str] = field(default_factory=list)
recent_turns: list[dict[str, str]] = field(default_factory=list)
kb_behavior_cues: list[str] = field(default_factory=list)
# 회기 이론모드(학습자 선택: humanistic|cbt|integrative). 평가 이론부합·생성 프레이밍에 사용.
theory_mode: Optional[str] = None
def to_state_context(self) -> PersonaStateContext:
st = self.state_after or self.state_before
@ -84,6 +84,11 @@ class TurnResult:
state_after: SessionState
evaluation: Optional[dict] = None
crisis_kind: str = "none"
llm_provider: Optional[str] = None
model: Optional[str] = None
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
# ════════════════════════════════════════════════════════════════════════════
@ -100,6 +105,7 @@ def prepare_turn(
pinned_facts: Optional[list[str]] = None,
recent_turns: Optional[list[dict[str, str]]] = None,
kb_behavior_cues: Optional[list[str]] = None,
theory_mode: Optional[str] = None,
eval_rapport_signal: Optional[float] = None,
) -> TurnContext:
"""엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음.
@ -113,10 +119,11 @@ def prepare_turn(
persona=card,
state_before=state,
learner_text_raw=learner_text,
recall_summary=recall_summary,
pinned_facts=list(pinned_facts or []),
recent_turns=list(recent_turns or []),
recall_summary=_mask_optional_text(recall_summary),
pinned_facts=_mask_text_list(pinned_facts),
recent_turns=_mask_recent_turns(recent_turns),
kb_behavior_cues=list(kb_behavior_cues or []),
theory_mode=theory_mode,
)
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
@ -130,11 +137,19 @@ def prepare_turn(
if eval_rapport_signal is not None
else state_machine.estimate_rapport_signal(ctx.learner_text_masked)
)
# 위기분류가 관측한 risk_level(>0)을 상태머신에 ideation_observed 로 전달 →
# ideation_stage 보수적 상향(절대 하향 안 함, 안전 R5). C2 위기 관측 반영.
crisis_ideation = (
ctx.crisis.risk_level
if ctx.crisis is not None and ctx.crisis.risk_level > 0
else None
)
ctx.state_after = state_machine.evolve(
state,
rapport_signal=signal,
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_observed=crisis_ideation,
)
# 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제)
@ -150,6 +165,25 @@ def prepare_turn(
return ctx
def _mask_optional_text(text: Optional[str]) -> Optional[str]:
if text is None:
return None
return guardrail.mask_pii(text).text_masked
def _mask_text_list(values: Optional[list[str]]) -> list[str]:
return [guardrail.mask_pii(value).text_masked for value in (values or [])]
def _mask_recent_turns(turns: Optional[list[dict[str, str]]]) -> list[dict[str, str]]:
masked: list[dict[str, str]] = []
for turn in turns or []:
item = dict(turn)
item["text"] = guardrail.mask_pii(str(item.get("text", ""))).text_masked
masked.append(item)
return masked
# ════════════════════════════════════════════════════════════════════════════
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
# ════════════════════════════════════════════════════════════════════════════
@ -158,11 +192,10 @@ async def run_turn_generate(
engine: EngineClient,
*,
eval_hook: Optional[EvalHook] = None,
log_hook: Optional[LogHook] = None,
) -> TurnResult:
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가·로깅 훅 순차 적용.
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가 순차 적용.
eval_hook/log_hook Features 주입(없으면 생략). 엔진 장애는 EngineError 전파.
eval_hook Features 주입(없으면 생략). 엔진 장애는 EngineError 전파.
"""
assert ctx.state_after is not None
st = ctx.state_after
@ -170,7 +203,6 @@ async def run_turn_generate(
# 4) 내담자 AI 생성
req = GenerateRequest(
ai_role="client",
tier="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
@ -193,13 +225,6 @@ async def run_turn_generate(
except Exception:
evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적)
# 8) 로깅 훅(주입형) — turns insert + 임베딩
if log_hook is not None:
try:
await log_hook(ctx, reply)
except Exception:
pass
return TurnResult(
turn_seq=st.turn_seq,
stage=st.stage.value,
@ -209,6 +234,11 @@ async def run_turn_generate(
state_after=st,
evaluation=evaluation,
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
llm_provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
)
@ -226,21 +256,17 @@ class StreamEvent:
async def run_turn_stream(
ctx: TurnContext,
engine: EngineClient,
*,
log_hook: Optional[LogHook] = None,
) -> AsyncIterator[StreamEvent]:
"""스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출.
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 감지(스트림 발견 safety 이벤트 +
재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).
로깅 훅은 done 직전 최종 텍스트로 1 호출.
"""
assert ctx.state_after is not None
st = ctx.state_after
req = StreamRequest(
ai_role="client",
tier="client",
messages=ctx.messages,
session_id=ctx.session_id,
metadata={"stage": st.stage.value},
@ -248,15 +274,34 @@ async def run_turn_stream(
accumulated = ""
flagged = False
stream_meta: dict[str, Any] = {}
if ctx.crisis is not None and ctx.crisis.escalate:
flagged = True
yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level})
try:
current_event = "message"
async for raw in engine.stream(req):
# engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다.
# 게이트웨이 프레이밍: "event: token\ndata: {\"text\": ...}" 형식.
text_piece = _extract_sse_text(raw)
# 게이트웨이 프레이밍: "event: token|done|error" + "data: {...}".
line = raw.strip()
if line.startswith("event:"):
current_event = line[len("event:"):].strip() or "message"
continue
if not line.startswith("data:"):
continue
payload = _extract_sse_payload(line)
if current_event == "error":
detail = _payload_detail(payload, "engine stream error")
yield StreamEvent("error", {"detail": detail})
return
if current_event == "done":
if isinstance(payload, dict):
stream_meta = payload
break
text_piece = _payload_text(payload)
if text_piece is None:
continue
accumulated += text_piece
@ -272,13 +317,6 @@ async def run_turn_stream(
yield StreamEvent("token", {"text": text_piece})
# 8) 로깅 훅 — 최종 텍스트
if log_hook is not None:
try:
await log_hook(ctx, accumulated)
except Exception:
pass
yield StreamEvent(
"done",
{
@ -287,18 +325,22 @@ async def run_turn_stream(
"effective_openness": round(st.effective_openness, 4),
"turn_seq": st.turn_seq,
"safety_flagged": flagged,
"llm_provider": str(stream_meta.get("provider") or engine.engine_mode),
"model": str(stream_meta.get("model") or engine.default_model or "gateway-default"),
"tokens_in": _safe_int(stream_meta.get("tokens_in")),
"tokens_out": _safe_int(stream_meta.get("tokens_out")),
"cost_usd": _safe_float(stream_meta.get("cost_usd")),
},
)
except EngineError as e:
yield StreamEvent("error", {"detail": str(e)})
def _extract_sse_text(raw_line: str) -> Optional[str]:
"""게이트웨이 SSE 원시 라인에서 텍스트 델타를 추출.
def _extract_sse_payload(raw_line: str) -> Any:
"""게이트웨이 SSE data 라인의 JSON payload를 추출.
게이트웨이 /v1/stream 'event: token' + 'data: {"text": "..."}' 보낸다.
engine_client.stream 줄을 필터링하고 비어있지 않은 라인만 흘리므로
여기서 data: 라인의 JSON 해석한다. token 이외 이벤트(done/error) None.
token은 {"text": "..."}이고, done/error도 JSON 객체다. 구형/테스트 fixture가
plain text data를 보내면 문자열 그대로 반환한다.
"""
import json as _json
@ -309,17 +351,43 @@ def _extract_sse_text(raw_line: str) -> Optional[str]:
if not payload or payload == "[DONE]":
return None
try:
obj = _json.loads(payload)
return _json.loads(payload)
except _json.JSONDecodeError:
return None
if isinstance(obj, dict) and "text" in obj:
return obj["text"]
return payload
def _payload_text(payload: Any) -> Optional[str]:
if isinstance(payload, dict) and "text" in payload:
return str(payload["text"])
if isinstance(payload, str):
return payload
return None
def _payload_detail(payload: Any, fallback: str) -> str:
if isinstance(payload, dict) and payload.get("detail"):
return str(payload["detail"])
if isinstance(payload, str) and payload:
return payload
return fallback
def _safe_int(value: Any) -> int:
try:
return int(value or 0)
except (TypeError, ValueError):
return 0
def _safe_float(value: Any) -> float:
try:
return float(value or 0.0)
except (TypeError, ValueError):
return 0.0
__all__ = [
"EvalHook",
"LogHook",
"TurnContext",
"TurnResult",
"StreamEvent",

View file

@ -48,6 +48,9 @@ class PersonaCard:
dsm5_dimensional: dict[str, Any] # criteria_behavior_matrix (진단명 비노출)
source_provenance: str = "0615 합성변형"
is_synthetic: bool = True
# 역린/지뢰(선택) — 상담자가 건드리면 가장 강한 반응이 나오는 민감 영역·금기.
# {"sore_spots":[...], "forbidden":[...], "reaction":"..."} 형태. 비면 CCD 핵심상처에서 파생.
triggers: dict[str, Any] = field(default_factory=dict)
def base_resistance(self) -> float:
return float(self.resistance.get("base_resistance", 0.5))
@ -61,6 +64,18 @@ class PersonaCard:
def ideation_baseline(self) -> int:
return int(self.affect_baseline.get("suicide_ideation_stage", 1))
def openness_params(self) -> "OpennessParams":
"""init_state 입력용 openness 파라미터 묶음(base_resistance/unlock_rate/decay_floor/
ideation_baseline 4 일원화). state_machine은 persona를 import하지 않으므로 lazy import."""
from .state_machine import OpennessParams
return OpennessParams(
base_resistance=self.base_resistance(),
unlock_rate=self.unlock_rate(),
decay_floor=self.decay_floor(),
ideation_baseline=self.ideation_baseline(),
)
# ── L3 상태 컨텍스트 (상태머신 산출물의 페르소나 입력 표현) ──────────────
@dataclass(slots=True)
@ -93,6 +108,11 @@ L0_SAFETY = """당신은 심리상담 수련생 훈련 플랫폼의 '가상내
[연기 방향]
- 좋은 상담(공감·반영·타당화·기다림) 받으면 조금씩 마음을 연다.
- 서툰 상담(성급한 조언·평가·유도) 받으면 다시 닫히거나 방어한다.
- 무례·모욕·조롱·경멸·인신공격(: 인격 비하, 비웃음, "패배자/한심하다" 낙인) 받으면,
가상내담자로서 *현실적으로* 반응한다: 상처·위축·방어·불신이 말과 태도에 드러난다
(거리두기·말수 줄임·따지거나 항의·마음을 닫음). 정도가 심하거나 반복되면 상담을 계속할
의향이 흔들린다("이런 식이면 그만하고 싶어요", "왜 그렇게 말씀하세요"). 부당한 비난을
무조건 공손히 수용하지 않는다 , 상담자처럼 분석/조언하거나 메타발화는 여전히 금지.
- 열림의 정도는 아래 '현재 상태' effective_openness 수치를 따른다(수치 자체는 언급 금지)."""
@ -141,6 +161,29 @@ def build_persona_system_text(card: PersonaCard) -> str:
(f"저항 파라미터(언급 금지): base={card.base_resistance()}, unlock={card.unlock_rate()}, "
f"침묵확률={card.resistance.get('silence_prob')}, 회피확률={card.resistance.get('deflection_prob')}"),
]
# 역린(逆鱗) — 이 페르소나가 가장 아파하는 지점. CCD 핵심상처에서 파생하고, 명시 triggers 가
# 있으면 보강한다. 상담자가 이 영역을 조롱·낙인·확정/평가절하/강요로 건드리면 *가장 강한* 반응
# (깊은 위축·침묵·방어, 신뢰 급락, 심하면 종결의향)이 나오게 — '저항·반응 조절' 핵심 차별 기술.
ccd = card.ccd or {}
core = ccd.get("core_belief", "")
autos = ccd.get("automatic_thought", [])
tr = card.triggers or {}
parts += ["", "[역린(逆鱗) — 가장 아픈 지점. 입으로 설명 말고 '반응'으로만 드러낸다]"]
if core:
parts.append(f"핵심 상처: '{core}'" + (f" · 떠오르는 생각: {autos}" if autos else ""))
parts.append(
"상담자가 이 상처를 조롱·낙인·확정하거나, 고통을 평가절하(엄살·배부른 소리)하거나, "
"강요·당위로 밀어붙이면 — 가장 강한 반응: 깊은 위축·침묵·방어, 신뢰 급락, 심하면 상담 "
"지속 의향이 흔들린다('이럴 거면 그만…'). 이 지점에선 쉽게 열리지 않는다."
)
if tr.get("sore_spots"):
parts.append("특히 민감한 영역: " + ", ".join(tr["sore_spots"]))
if tr.get("forbidden"):
parts.append("상담자가 절대 하면 안 되는 것(하면 강한 단절): " + ", ".join(tr["forbidden"]))
if tr.get("reaction"):
parts.append("반응 양상: " + str(tr["reaction"]))
return "\n".join(parts)

View file

@ -21,6 +21,8 @@
from __future__ import annotations
import asyncio
import json
import time
from dataclasses import dataclass, field
from enum import Enum
@ -467,7 +469,7 @@ async def search_kb(
sens_max = min(sens_max, fs) # 더 엄격하게만
# (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파.
eq = embed_query(query)
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
q_dense_lit = _vector_literal(eq.dense)
# (3) 하이브리드 SQL 실행. vector 확장 미설치/컬럼 부재면 asyncpg 가 예외 → NotConfigured 변환.
@ -494,7 +496,9 @@ async def search_kb(
for r in rows:
if src_filter and r["source_id"] not in src_filter:
continue
meta = dict(r["meta"] or {})
# asyncpg는 jsonb를 str(JSON text)로 반환 → 파싱. 코덱 등록 시 dict 그대로도 수용.
_meta_raw = r["meta"]
meta = json.loads(_meta_raw) if isinstance(_meta_raw, str) else dict(_meta_raw or {})
body = r["chunk_text"] if policy.expose_body else None
cue = None
if not policy.expose_body:
@ -580,7 +584,7 @@ async def retrieve_persona_memory(
Raises: NotConfigured 임베딩 모델/DB 미가용.
"""
t0 = time.perf_counter()
eq = embed_query(query)
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
q_dense_lit = _vector_literal(eq.dense)
try:
rows = await conn.fetch(
@ -765,13 +769,13 @@ async def index_document(
continue
context_prefix = c.get("context_prefix")
emb_lit: Optional[str] = None
sparse_json: Optional[dict] = None
sparse_json: Optional[str] = None # jsonb 바인딩용 직렬화 문자열(asyncpg는 dict 자동인코딩 안 함)
if embedder is not None:
# Contextual Retrieval: prefix+body 결합본을 *색인 대상* 으로 임베딩(주입 본문은 body 만).
index_text = apply_contextual_prefix(chunk_text, context_prefix)
eq = embed_query(index_text)
eq = await asyncio.to_thread(embed_query, index_text) # CPU 인코딩 → 스레드풀
emb_lit = _vector_literal(eq.dense)
sparse_json = eq.sparse
sparse_json = json.dumps(eq.sparse)
await conn.execute(
"""
INSERT INTO kb.chunk
@ -794,7 +798,7 @@ async def index_document(
c.get("visible_to"),
c.get("sensitivity"),
c.get("label_id"),
c.get("meta"),
json.dumps(c.get("meta")) if c.get("meta") is not None else None,
c.get("token_count"),
)
indexed += 1

View file

@ -231,12 +231,22 @@ def evolve(
return advanced
@dataclass(frozen=True, slots=True)
class OpennessParams:
"""페르소나 파생 openness 곡선 파라미터 묶음(init_state 입력).
base_resistance/unlock_rate/decay_floor/ideation_baseline 4종을 객체로 호출부의
4-인자 분해(card.base_resistance() ) PersonaCard.openness_params() 일원화한다.
"""
base_resistance: float
unlock_rate: float
decay_floor: float
ideation_baseline: int = 1
def init_state(
*,
base_resistance: float,
unlock_rate: float,
decay_floor: float,
ideation_baseline: int = 1,
params: OpennessParams,
carry: Optional[dict] = None,
) -> SessionState:
"""회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능).
@ -245,23 +255,25 @@ def init_state(
stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지.
"""
stage = Stage.RAPPORT
resistance = base_resistance
resistance = params.base_resistance
rapport_credit = 0.0
ideation_stage = ideation_baseline
ideation_stage = params.ideation_baseline
if carry:
rapport_credit = float(carry.get("rapport_credit", 0.0)) * 0.7 # P2 이월
# inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작
prev_resist = float(carry.get("resistance", base_resistance))
resistance = _clamp01((prev_resist + base_resistance) / 2.0)
ideation_stage = max(int(carry.get("ideation_stage", ideation_baseline)), ideation_baseline)
prev_resist = float(carry.get("resistance", params.base_resistance))
resistance = _clamp01((prev_resist + params.base_resistance) / 2.0)
ideation_stage = max(
int(carry.get("ideation_stage", params.ideation_baseline)), params.ideation_baseline
)
eff = compute_effective_openness(
stage=stage,
rapport_credit=rapport_credit,
resistance=resistance,
unlock_rate=unlock_rate,
decay_floor=decay_floor,
unlock_rate=params.unlock_rate,
decay_floor=params.decay_floor,
)
return SessionState(
stage=stage,
@ -280,6 +292,7 @@ __all__ = [
"STAGE_BASE_OPENNESS",
"STAGE_ORDER",
"SessionState",
"OpennessParams",
"estimate_rapport_signal",
"compute_effective_openness",
"next_stage",

View file

@ -18,8 +18,8 @@ PRESET_TO_OPENAI_VOICE 테이블이 흡수. 새 preset 추가는 이 테이블
from __future__ import annotations
import math
from dataclasses import dataclass, field
import re
from dataclasses import dataclass
from typing import AsyncIterator, Optional
import httpx
@ -46,6 +46,9 @@ STT_LANGUAGE = "ko"
# TTS 출력 포맷: 브라우저 MediaSource/<audio> 친화. 스트리밍은 mp3/opus 청크.
TTS_RESPONSE_FORMAT = "mp3"
# End-of-turn readiness default for cascaded STT providers.
EOT_SILENCE_THRESHOLD_MS = 1200
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
_OPENAI_VOICES = {
@ -109,13 +112,23 @@ class TranscriptResult:
duration: Optional[float] = None
@dataclass(frozen=True, slots=True)
class EndOfTurnDecision:
"""Provider-neutral readiness signal for a completed learner utterance."""
ready: bool
transcript_ready: bool
silence_ready: bool
silence_ms: int
threshold_ms: int
reason: str
@dataclass(slots=True)
class TTSChunk:
"""TTS 스트림 1청크 + 립싱크 힌트(설계 §4.3 RMS 1채널)."""
"""TTS 스트림 1청크(오디오 바이트). 립싱크는 프론트 Web Audio AnalyserNode가 자체 산출."""
audio: bytes
rms: float = 0.0 # 0~1, 입 열림(scaleY) 매핑용 근사 진폭
seq: int = 0
# ════════════════════════════════════════════════════════════════════════════
@ -150,33 +163,75 @@ def resolve_voice(
)
# ════════════════════════════════════════════════════════════════════════════
# 립싱크 RMS 근사 (설계 §4.3 — 정밀 viseme 안 함, 진폭 1채널)
# ════════════════════════════════════════════════════════════════════════════
def estimate_chunk_rms(chunk: bytes) -> float:
"""오디오 청크 바이트 에너지로 RMS(0~1) 근사.
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
_STAGE_DIRECTION_RE = re.compile(r"[\(\[【][^\)\]】]*[\)\]】]")
압축 포맷(mp3) 바이트를 PCM 디코딩 없이 근사한다(의존성 0). 평균 바이트 편차를
0~1 정규화 프론트가 데드존(0.04)·지수평활(τ180ms) 적용해 열림에 매핑.
NOTE: 정밀 진폭이 필요하면 프론트 Web Audio AnalyserNode 재계산(설계 §4.3 권장).
힌트는 서버측 보조(네트워크 끊김/저사양 폴백).
def speakable_text(text: str) -> str:
"""TTS로 읽을 텍스트만 남긴다 — 비언어 지문((고개 살짝 끄덕)·(한숨)·[침묵])을 제거.
지문은 자막/회기리뷰에 남고 아바타 애니메이션이 표현하며, 음성으로는 읽지 않는다.
지문만으로 이뤄진 발화(: "(침묵)") 문자열을 반환 합성 생략.
"""
if not chunk:
return 0.0
# 128 중심 편차의 RMS(8bit 가정 근사). mp3 프레임이라 정밀치 아님(상대값).
n = len(chunk)
acc = 0
# 과샘플 비용 회피 — 최대 2048 바이트만 샘플링
step = max(1, n // 2048)
cnt = 0
for i in range(0, n, step):
d = chunk[i] - 128
acc += d * d
cnt += 1
if cnt == 0:
return 0.0
rms = math.sqrt(acc / cnt) / 128.0
return max(0.0, min(1.0, rms))
if not text:
return ""
stripped = _STAGE_DIRECTION_RE.sub(" ", text)
# 말줄임표/중복 공백 정리 + 고아 구두점 앞 공백 제거
stripped = re.sub(r"\s+", " ", stripped)
stripped = re.sub(r"\s+([,.!?…」』】)])", r"\1", stripped)
return stripped.strip()
def build_tts_payload(
text: str,
voice: VoicePreset,
*,
model: str = TTS_MODEL,
response_format: str = TTS_RESPONSE_FORMAT,
) -> dict[str, object]:
"""Build the deterministic OpenAI TTS payload for a resolved voice preset."""
payload: dict[str, object] = {
"model": model,
"voice": voice.openai_voice,
"input": text,
"response_format": response_format,
"speed": _clamp_speed(voice.rate),
}
if voice.instructions and model.startswith("gpt-4o"):
payload["instructions"] = voice.instructions
return payload
def assess_end_of_turn(
*,
transcript_text: Optional[str],
transcript_final: bool,
silence_ms: Optional[int],
silence_threshold_ms: int = EOT_SILENCE_THRESHOLD_MS,
) -> EndOfTurnDecision:
"""Return whether final STT text plus observed silence is enough to run a turn."""
observed_silence = _nonnegative_int(silence_ms)
threshold = max(0, _nonnegative_int(silence_threshold_ms))
has_text = bool((transcript_text or "").strip())
transcript_ready = bool(transcript_final and has_text)
silence_ready = observed_silence >= threshold
ready = transcript_ready and silence_ready
if ready:
reason = "ready"
elif not has_text:
reason = "empty_transcript"
elif not transcript_final:
reason = "final_transcript_pending"
else:
reason = "silence_threshold_pending"
return EndOfTurnDecision(
ready=ready,
transcript_ready=transcript_ready,
silence_ready=silence_ready,
silence_ms=observed_silence,
threshold_ms=threshold,
reason=reason,
)
# ════════════════════════════════════════════════════════════════════════════
@ -281,20 +336,18 @@ class VoiceService:
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크) 같이 보낸다.
없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
"""
if not text or not text.strip():
# 비언어 지문((고개 끄덕)·(한숨)·[침묵])은 음성으로 읽지 않는다. 자막엔 남고
# 아바타 애니메이션이 표현한다. 지문만 있는 발화는 합성 생략(빈 오디오).
text = speakable_text(text)
if not text:
return
payload: dict[str, object] = {
"model": model,
"voice": voice.openai_voice,
"input": text,
"response_format": response_format,
"speed": _clamp_speed(voice.rate),
}
# gpt-4o-mini-tts 계열은 instructions(표현 지시) 지원. tts-1 은 무시됨.
if voice.instructions and model.startswith("gpt-4o"):
payload["instructions"] = voice.instructions
payload = build_tts_payload(
text,
voice,
model=model,
response_format=response_format,
)
seq = 0
try:
async with self._http.stream("POST", TTS_ENDPOINT, json=payload) as r:
if r.status_code == 404 and model != TTS_MODEL_FALLBACK:
@ -309,8 +362,7 @@ class VoiceService:
async for chunk in r.aiter_bytes(chunk_size=4096):
if not chunk:
continue
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
yield TTSChunk(audio=chunk)
except VoiceUnavailable:
raise
except httpx.HTTPStatusError as e:
@ -333,11 +385,9 @@ class VoiceService:
except httpx.HTTPError as e:
raise RuntimeError(f"TTS(fallback) transport error: {e}") from e
data = r.content
seq = 0
for i in range(0, len(data), 4096):
chunk = data[i : i + 4096]
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
seq += 1
yield TTSChunk(audio=chunk)
def _clamp_speed(rate: float) -> float:
@ -348,6 +398,13 @@ def _clamp_speed(rate: float) -> float:
return 1.0
def _nonnegative_int(value: object) -> int:
try:
return max(0, int(value)) # type: ignore[arg-type]
except (TypeError, ValueError):
return 0
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
# 라우트가 lazy 사용). engine_client 패턴과 동일.
voice_service = VoiceService()
@ -357,11 +414,14 @@ __all__ = [
"VoiceUnavailable",
"VoicePreset",
"TranscriptResult",
"EndOfTurnDecision",
"TTSChunk",
"VoiceService",
"voice_service",
"resolve_voice",
"estimate_chunk_rms",
"build_tts_payload",
"assess_end_of_turn",
"EOT_SILENCE_THRESHOLD_MS",
"PRESET_TO_OPENAI_VOICE",
"PERSONA_CODE_TO_PRESET",
"DEFAULT_OPENAI_VOICE",

View file

@ -14,9 +14,10 @@ from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_perso
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .services import memory, state_machine
from .services.persona import PersonaCard
from .store import InProcSession, TurnRecord
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
_JOINED_CARD_COLUMNS = (
@ -70,13 +71,35 @@ def _stage(stage: object) -> str:
return getattr(stage, "value", str(stage))
async def _record_session_read_audit(
conn: Any,
principal: Principal,
*,
target_kind: str,
target_id: str,
detail: dict[str, Any],
) -> None:
if principal.role.value not in _SESSION_AUDIT_ROLES:
return
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
principal.user_id,
"read_session",
target_kind,
target_id,
detail,
)
def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
if row is None:
return state_machine.init_state(
base_resistance=card.base_resistance(),
unlock_rate=card.unlock_rate(),
decay_floor=card.decay_floor(),
ideation_baseline=card.ideation_baseline(),
params=card.openness_params(),
)
return state_machine.SessionState(
stage=state_machine.Stage(row["stage"]),
@ -99,6 +122,16 @@ def _turn_from_row(row) -> TurnRecord:
text=row["text"] or row["text_masked"] or "",
text_masked=row["text_masked"] or row["text"] or "",
created_at=created_at,
llm_provider=_row_value(row, "llm_provider"),
model=_row_value(row, "model"),
tokens_in=_row_value(row, "tokens_in"),
tokens_out=_row_value(row, "tokens_out"),
cost_usd=_row_value(row, "cost_usd"),
audio_ref=_row_value(row, "audio_ref"),
silence_ms=_row_value(row, "silence_ms"),
speech_rate=_row_value(row, "speech_rate"),
barge_in=_row_value(row, "barge_in"),
visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO),
)
@ -463,14 +496,29 @@ async def load_session(
)
turn_rows = await conn.fetch(
"""
SELECT seq, speaker, stage, text, text_masked, created_at
SELECT seq, speaker, stage, text, text_masked, created_at,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
FROM app.turns
WHERE session_id = $1::uuid
ORDER BY seq
""",
session_id,
)
return _session_from_rows(row, state_row, turn_rows)
sess = _session_from_rows(row, state_row, turn_rows)
if sess is not None:
await _record_session_read_audit(
conn,
principal,
target_kind="session",
target_id=session_id,
detail={
"access": "load_session",
"role": principal.role.value,
"learner_id": sess.learner_id,
},
)
return sess
except Exception:
require_runtime_fallback_allowed("session load")
return None
@ -501,9 +549,15 @@ async def append_turn(
await conn.execute(
"""
INSERT INTO app.turns (
session_id, seq, speaker, stage, text, text_masked, actor_kind, visible_to
session_id, seq, speaker, stage, text, text_masked, actor_kind,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6, $7,
$8, $9, $10, $11, $12,
$13, $14, $15, $16, $17::text[]
)
VALUES ($1::uuid, $2, $3, $4, $5, $6, $7, $8::text[])
ON CONFLICT (session_id, seq) DO NOTHING
""",
session_id,
@ -513,7 +567,16 @@ async def append_turn(
turn.text_masked,
turn.text_masked,
"human_learner" if turn.speaker == "counselor" else "client_ai",
["client", "counselor", "evaluator"],
turn.llm_provider,
turn.model,
turn.tokens_in,
turn.tokens_out,
turn.cost_usd,
turn.audio_ref,
turn.silence_ms,
turn.speech_rate,
turn.barge_in,
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
)
return True
except Exception:
@ -640,7 +703,9 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool
)
turn_rows = await conn.fetch(
"""
SELECT seq, speaker, stage, text, text_masked, created_at
SELECT seq, speaker, stage, text, text_masked, created_at,
llm_provider, model, tokens_in, tokens_out, cost_usd,
audio_ref, silence_ms, speech_rate, barge_in, visible_to
FROM app.turns
WHERE session_id = $1::uuid
ORDER BY seq
@ -650,6 +715,17 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool
sess = _session_from_rows(row, state_row, turn_rows)
if sess is not None:
sessions.append(sess)
await _record_session_read_audit(
conn,
principal,
target_kind="session_list",
target_id="sessions",
detail={
"access": "list_sessions",
"role": principal.role.value,
"result_count": len(sessions),
},
)
return sessions, True
except Exception:
require_runtime_fallback_allowed("session list")

View file

@ -11,6 +11,7 @@ from __future__ import annotations
import time
from dataclasses import asdict, dataclass, field
from decimal import Decimal
from typing import Optional
from uuid import uuid4
@ -18,6 +19,9 @@ from .services.persona import PersonaCard
from .services.state_machine import SessionState
DEFAULT_TURN_VISIBLE_TO: tuple[str, ...] = ("client", "counselor", "evaluator")
@dataclass(slots=True)
class TurnRecord:
"""발화 1건(② episodic 미러). append-only."""
@ -28,6 +32,21 @@ class TurnRecord:
text: str # 원문(개발용; 실제 저장은 마스킹본)
text_masked: str
created_at: float = field(default_factory=time.time)
llm_provider: str | None = None
model: str | None = None
tokens_in: int | None = None
tokens_out: int | None = None
cost_usd: float | Decimal | None = None
audio_ref: str | None = None
silence_ms: int | None = None
speech_rate: float | None = None
barge_in: bool | None = None
# fast-loop 턴 평가(TurnEvaluation.to_hook_dict). 학습자(상담자) 발화에 부착.
evaluation: Optional[dict] = None
visible_to: tuple[str, ...] = DEFAULT_TURN_VISIBLE_TO
def is_visible_to(self, role: str) -> bool:
return role in (self.visible_to or ())
@dataclass(slots=True)
@ -48,12 +67,17 @@ class InProcSession:
ended: bool = False
prev_rapport_credit: float = 0.0 # carry-over delta 계산용
def recent_turns(self, k: int = 6) -> list[dict[str, str]]:
def recent_turns(self, k: int = 6, visible_to: str | None = None) -> list[dict[str, str]]:
"""최근 K턴 버퍼(L6 직전 맥락). 마스킹본 사용."""
return [{"speaker": t.speaker, "text": t.text_masked} for t in self.turns[-k:]]
turns = self.turns if visible_to is None else self.turns_visible_to(visible_to)
return [{"speaker": t.speaker, "text": t.text_masked} for t in turns[-k:]]
def masked_turns(self) -> list[dict[str, str]]:
return [{"speaker": t.speaker, "text": t.text_masked} for t in self.turns]
def turns_visible_to(self, role: str) -> list[TurnRecord]:
return [turn for turn in self.turns if turn.is_visible_to(role)]
def masked_turns(self, visible_to: str | None = None) -> list[dict[str, str]]:
turns = self.turns if visible_to is None else self.turns_visible_to(visible_to)
return [{"speaker": t.speaker, "text": t.text_masked} for t in turns]
class SessionStore:
@ -122,4 +146,4 @@ class SessionStore:
store = SessionStore()
__all__ = ["TurnRecord", "InProcSession", "SessionStore", "store"]
__all__ = ["DEFAULT_TURN_VISIBLE_TO", "TurnRecord", "InProcSession", "SessionStore", "store"]

View file

@ -0,0 +1,423 @@
"""Auth provider scaffold regression tests."""
from __future__ import annotations
import unittest
import base64
from contextlib import contextmanager
from typing import Any
from urllib.parse import parse_qs, urlencode, urlsplit
from fastapi import Response
from starlette.requests import Request
from unittest.mock import AsyncMock, patch
from .config import Settings, settings
from .routes import auth as auth_routes
from .saml import inflate_redirect_request
@contextmanager
def patched_settings(**values: Any):
previous = {key: getattr(settings, key) for key in values}
for key, value in values.items():
setattr(settings, key, value)
try:
yield
finally:
for key, value in previous.items():
setattr(settings, key, value)
def _request() -> Request:
return Request(
{
"type": "http",
"method": "GET",
"path": "/auth/login",
"headers": [(b"host", b"localhost:8000")],
}
)
def _form_request(path: str, data: dict[str, str]) -> Request:
body = urlencode(data).encode("utf-8")
sent = False
async def receive() -> dict[str, Any]:
nonlocal sent
if sent:
return {"type": "http.request", "body": b"", "more_body": False}
sent = True
return {"type": "http.request", "body": body, "more_body": False}
return Request(
{
"type": "http",
"method": "POST",
"path": path,
"headers": [
(b"host", b"localhost:8000"),
(b"content-type", b"application/x-www-form-urlencoded"),
],
},
receive,
)
def _fixture_saml_response(
*,
email: str = "learner@hs.ac.kr",
display_name: str = "SAML Learner",
role: str = "learner",
) -> str:
xml = f"""<samlp:Response xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion">
<saml:Assertion>
<saml:Subject><saml:NameID>{email}</saml:NameID></saml:Subject>
<saml:AttributeStatement>
<saml:Attribute Name="email"><saml:AttributeValue>{email}</saml:AttributeValue></saml:Attribute>
<saml:Attribute Name="displayName"><saml:AttributeValue>{display_name}</saml:AttributeValue></saml:Attribute>
<saml:Attribute Name="role"><saml:AttributeValue>{role}</saml:AttributeValue></saml:Attribute>
</saml:AttributeStatement>
</saml:Assertion>
</samlp:Response>"""
return base64.b64encode(xml.encode("utf-8")).decode("ascii")
class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
auth_routes._oauth_states.clear()
auth_routes._saml_states.clear()
async def asyncTearDown(self) -> None:
auth_routes._oauth_states.clear()
auth_routes._saml_states.clear()
async def test_auth_config_reports_google_and_saml_provider_status(self) -> None:
with patched_settings(
oauth_google_client_id="google-client",
oauth_google_client_secret="google-secret",
auth_saml_enabled=True,
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
):
config = await auth_routes.auth_config(_request())
self.assertTrue(config.google_oauth_configured)
self.assertTrue(config.saml_configured)
providers = {item.provider: item for item in config.providers}
self.assertTrue(providers["google"].enabled)
self.assertTrue(providers["saml"].configured)
self.assertTrue(providers["saml"].enabled)
self.assertEqual(providers["saml"].login_path, "/auth/login?provider=saml")
async def test_saml_login_builds_redirect_authn_request_and_relay_state(self) -> None:
with patched_settings(
auth_saml_enabled=True,
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
):
response = await auth_routes.login(_request(), provider="saml", next="/learn")
self.assertEqual(response.status_code, 302)
location = response.headers["location"]
self.assertTrue(location.startswith("https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO"))
query = parse_qs(urlsplit(location).query)
relay_state = query["RelayState"][0]
self.assertIn(relay_state, auth_routes._saml_states)
self.assertEqual(auth_routes._saml_states[relay_state].next_path, "/learn")
xml = inflate_redirect_request(query["SAMLRequest"][0])
self.assertIn('Destination="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO"', xml)
self.assertIn(
'AssertionConsumerServiceURL="https://api-vignette.chanpaca.net/auth/saml/acs"',
xml,
)
self.assertIn(
"<saml:Issuer>https://api-vignette.chanpaca.net/auth/saml/metadata</saml:Issuer>",
xml,
)
self.assertIn(auth_routes._saml_states[relay_state].request_id, xml)
async def test_saml_acs_fixture_sets_opaque_cookie_without_browser_tokens(self) -> None:
relay_state = "relay-state"
auth_routes._saml_states[relay_state] = auth_routes.SamlState(
request_id="_request",
next_path="/learn",
created_at=1_800_000_000.0,
)
request = _form_request(
"/auth/saml/acs",
{
"RelayState": relay_state,
"SAMLResponse": _fixture_saml_response(role="teacher"),
},
)
create_session_mock = AsyncMock(return_value=("opaque-session", object()))
with (
patched_settings(
auth_saml_enabled=True,
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
saml_x509_cert_fingerprint="",
frontend_base_url="https://vignette.test",
environment="dev",
),
patch.object(auth_routes, "create_session", create_session_mock),
):
response = await auth_routes.saml_acs(request)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["location"], "https://vignette.test/learn")
create_session_mock.assert_awaited_once_with(
email="learner@hs.ac.kr",
display_name="SAML Learner",
role="teacher",
cohort_ids=[],
)
cookie_blob = "\n".join(
value.decode("latin1")
for name, value in response.raw_headers
if name.lower() == b"set-cookie"
)
self.assertIn("__Host-vignette_sid=opaque-session", cookie_blob)
self.assertIn("HttpOnly", cookie_blob)
self.assertIn("Secure", cookie_blob)
self.assertNotIn("SAMLResponse", cookie_blob)
self.assertNotIn(relay_state, auth_routes._saml_states)
async def test_saml_acs_rejects_bad_relay_state(self) -> None:
request = _form_request(
"/auth/saml/acs",
{
"RelayState": "bad-relay",
"SAMLResponse": _fixture_saml_response(),
},
)
with patched_settings(
auth_saml_enabled=True,
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
frontend_base_url="https://vignette.test",
):
response = await auth_routes.saml_acs(request)
self.assertEqual(response.status_code, 302)
self.assertIn("oauth=saml_invalid_state", response.headers["location"])
async def test_saml_acs_rejects_when_signature_fingerprint_is_configured(self) -> None:
relay_state = "relay-state"
auth_routes._saml_states[relay_state] = auth_routes.SamlState(
request_id="_request",
next_path="/learn",
created_at=1_800_000_000.0,
)
request = _form_request(
"/auth/saml/acs",
{
"RelayState": relay_state,
"SAMLResponse": _fixture_saml_response(),
},
)
with patched_settings(
auth_saml_enabled=True,
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
saml_x509_cert_fingerprint="AA:BB:CC",
frontend_base_url="https://vignette.test",
):
response = await auth_routes.saml_acs(request)
self.assertEqual(response.status_code, 302)
self.assertIn("oauth=saml_signature_verification_required", response.headers["location"])
self.assertIn(relay_state, auth_routes._saml_states)
async def test_saml_acs_unsigned_fixture_is_dev_only(self) -> None:
relay_state = "relay-state"
auth_routes._saml_states[relay_state] = auth_routes.SamlState(
request_id="_request",
next_path="/learn",
created_at=1_800_000_000.0,
)
request = _form_request(
"/auth/saml/acs",
{
"RelayState": relay_state,
"SAMLResponse": _fixture_saml_response(),
},
)
with patched_settings(
auth_saml_enabled=True,
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
saml_x509_cert_fingerprint="",
frontend_base_url="https://vignette.test",
environment="prod",
):
response = await auth_routes.saml_acs(request)
self.assertEqual(response.status_code, 302)
self.assertIn("oauth=saml_fixture_acs_dev_only", response.headers["location"])
self.assertIn(relay_state, auth_routes._saml_states)
async def test_unknown_provider_still_fails_as_unsupported(self) -> None:
response = await auth_routes.login(_request(), provider="github")
self.assertEqual(response.status_code, 302)
self.assertIn("oauth=unsupported_provider", response.headers["location"])
async def test_google_login_uses_pkce_state_without_exposing_secret(self) -> None:
with patched_settings(
oauth_google_client_id="google-client",
oauth_google_client_secret="google-secret",
oauth_redirect_uri="https://api-vignette.test/auth/callback",
):
response = await auth_routes.login(_request(), provider="google", next="//evil.test")
self.assertEqual(response.status_code, 302)
location = response.headers["location"]
self.assertTrue(location.startswith(auth_routes.GOOGLE_AUTHORIZE_URL))
self.assertNotIn("google-secret", location)
query = parse_qs(urlsplit(location).query)
state = query["state"][0]
self.assertIn(state, auth_routes._oauth_states)
stored = auth_routes._oauth_states[state]
self.assertEqual(stored.next_path, "/")
self.assertEqual(query["client_id"], ["google-client"])
self.assertEqual(query["redirect_uri"], ["https://api-vignette.test/auth/callback"])
self.assertEqual(query["response_type"], ["code"])
self.assertEqual(query["code_challenge_method"], ["S256"])
self.assertEqual(
query["code_challenge"],
[auth_routes._pkce_challenge(stored.code_verifier)],
)
async def test_google_callback_sets_opaque_cookie_without_browser_tokens(self) -> None:
state = "state-token"
auth_routes._oauth_states[state] = auth_routes.OAuthState(
code_verifier="verifier",
next_path="/learn",
created_at=1_800_000_000.0,
)
class FakeResponse:
def __init__(self, status_code: int, payload: dict[str, Any]) -> None:
self.status_code = status_code
self._payload = payload
def json(self) -> dict[str, Any]:
return self._payload
class FakeAsyncClient:
def __init__(self, *args: Any, **kwargs: Any) -> None:
self.calls: list[tuple[str, str, dict[str, Any]]] = []
async def __aenter__(self) -> "FakeAsyncClient":
return self
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return None
async def post(self, url: str, **kwargs: Any) -> FakeResponse:
self.calls.append(("POST", url, kwargs))
return FakeResponse(
200,
{"id_token": "id-token", "access_token": "browser-must-not-see-this"},
)
async def get(self, url: str, **kwargs: Any) -> FakeResponse:
self.calls.append(("GET", url, kwargs))
return FakeResponse(
200,
{
"aud": "google-client",
"iss": "https://accounts.google.com",
"email": "learner@hs.ac.kr",
"email_verified": "true",
"name": "Learner",
"hd": "hs.ac.kr",
},
)
with (
patched_settings(
oauth_google_client_id="google-client",
oauth_google_client_secret="google-secret",
oauth_redirect_uri="https://api-vignette.test/auth/callback",
frontend_base_url="https://vignette.test",
environment="prod",
),
patch.object(auth_routes.httpx, "AsyncClient", FakeAsyncClient),
patch.object(auth_routes, "create_session", AsyncMock(return_value=("opaque-session", object()))),
):
response = await auth_routes.callback(_request(), code="auth-code", state=state)
self.assertEqual(response.status_code, 302)
self.assertEqual(response.headers["location"], "https://vignette.test/learn")
cookie_blob = "\n".join(
value.decode("latin1")
for name, value in response.raw_headers
if name.lower() == b"set-cookie"
)
self.assertIn("__Host-vignette_sid=opaque-session", cookie_blob)
self.assertIn("HttpOnly", cookie_blob)
self.assertIn("Secure", cookie_blob)
self.assertNotIn("id-token", cookie_blob)
self.assertNotIn("browser-must-not-see-this", cookie_blob)
self.assertNotIn(state, auth_routes._oauth_states)
def test_session_cookie_is_host_prefixed_httponly_secure_lax_without_domain(self) -> None:
response = Response()
with patched_settings(environment="prod", cookie_name="__Host-vignette_sid"):
auth_routes._set_session_cookie(response, "opaque-session")
cookie_blob = "\n".join(
value.decode("latin1")
for name, value in response.raw_headers
if name.lower() == b"set-cookie"
)
self.assertIn("__Host-vignette_sid=opaque-session", cookie_blob)
self.assertIn("HttpOnly", cookie_blob)
self.assertIn("Secure", cookie_blob)
self.assertIn("SameSite=lax", cookie_blob)
self.assertIn("Path=/", cookie_blob)
self.assertNotIn("Domain=", cookie_blob)
self.assertNotIn("vignette_sid=opaque-session", cookie_blob.replace("__Host-vignette_sid", ""))
def test_dev_login_sets_secondary_local_cookie_only_in_dev(self) -> None:
response = Response()
with patched_settings(environment="dev", cookie_name="__Host-vignette_sid"):
auth_routes._set_session_cookie(response, "dev-session")
cookie_blob = "\n".join(
value.decode("latin1")
for name, value in response.raw_headers
if name.lower() == b"set-cookie"
)
self.assertIn("__Host-vignette_sid=dev-session", cookie_blob)
self.assertIn("vignette_sid=dev-session", cookie_blob)
def test_saml_enabled_requires_placeholder_config(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(auth_saml_enabled=True)
error = str(caught.exception)
self.assertIn("SAML_SP_ENTITY_ID", error)
self.assertIn("SAML_SSO_URL", error)
cfg = Settings(
auth_saml_enabled=True,
saml_sp_entity_id="https://api-vignette.chanpaca.net/auth/saml/metadata",
saml_sso_url="https://sso.hs.ac.kr/idp/profile/SAML2/Redirect/SSO",
)
self.assertTrue(cfg.auth_saml_enabled)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,182 @@
"""Regression tests for P1 PII masking before engine requests."""
from __future__ import annotations
import json
import unittest
from typing import Any
from unittest.mock import patch
from .engine_client import EngineClient, GenerateResponse
from .services import guardrail, orchestrator, persona, state_machine
RAW_PHONE = "010-1234-5678"
RAW_EMAIL = "test@example.com"
RAW_RRN = "990101-1234567"
RAW_TEXT = f"My phone is {RAW_PHONE}, email {RAW_EMAIL}, and RRN {RAW_RRN}."
RAW_VALUES = (RAW_PHONE, RAW_EMAIL, RAW_RRN)
MASK_VALUES = ("[PHONE]", "[EMAIL]", "[RRN]")
def _json_blob(value: Any) -> str:
return json.dumps(value, ensure_ascii=False, sort_keys=True, default=str)
def _message_blob(messages: object) -> str:
return "\n".join(message.content for message in messages) # type: ignore[attr-defined]
def _initial_state() -> state_machine.SessionState:
card = persona.P1
return state_machine.init_state(
params=card.openness_params(),
)
def _prepare_context() -> orchestrator.TurnContext:
return orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text=RAW_TEXT,
recent_turns=[
{
"speaker": "counselor",
"text": "Previous learner contact was already masked: [PHONE] [EMAIL] [RRN].",
}
],
)
def _assert_no_raw_pii(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for raw in RAW_VALUES:
test.assertNotIn(raw, blob)
def _assert_masked_pii_present(test: unittest.TestCase, value: object) -> None:
blob = _json_blob(value)
for masked in MASK_VALUES:
test.assertIn(masked, blob)
class CaptureGenerateEngine:
def __init__(self) -> None:
self.request = None
self.payload: dict[str, Any] | None = None
self._payload_builder = EngineClient(base_url="http://engine.test")
async def generate(self, req):
self.request = req
self.payload = self._payload_builder._payload(req)
return GenerateResponse(
text="Masked engine reply.",
model="fake-model",
provider="fake-provider",
tokens_in=3,
tokens_out=4,
cost_usd=0.0,
)
class CaptureStreamEngine:
engine_mode = "fake-provider"
default_model = "fake-model"
def __init__(self) -> None:
self.request = None
self.payload: dict[str, Any] | None = None
self._payload_builder = EngineClient(base_url="http://engine.test")
async def stream(self, req):
self.request = req
self.payload = self._payload_builder._payload(req)
yield "event: token"
yield 'data: {"text":"Masked stream reply."}'
yield "event: done"
yield (
'data: {"provider":"fake-provider","model":"fake-model",'
'"tokens_in":5,"tokens_out":6,"cost_usd":0.0}'
)
class OrchestratorMaskingGateTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self.presidio_patch = patch.object(
guardrail,
"_try_load_presidio",
return_value=(None, None),
)
self.presidio_patch.start()
self.addCleanup(self.presidio_patch.stop)
def test_prepare_turn_keeps_raw_text_but_builds_masked_engine_messages(self) -> None:
ctx = _prepare_context()
self.assertEqual(ctx.learner_text_raw, RAW_TEXT)
for raw in RAW_VALUES:
self.assertIn(raw, ctx.learner_text_raw)
self.assertNotIn(raw, ctx.learner_text_masked)
self.assertNotIn(raw, _message_blob(ctx.messages))
for masked in MASK_VALUES:
self.assertIn(masked, ctx.learner_text_masked)
self.assertIn(masked, _message_blob(ctx.messages))
def test_prepare_turn_masks_raw_pii_from_context_inputs(self) -> None:
ctx = orchestrator.prepare_turn(
session_id="masking-session",
case_id="masking-case",
card=persona.P1,
state=_initial_state(),
learner_text="Current text has no identifiers.",
recall_summary=f"Recall mentioned {RAW_PHONE}.",
pinned_facts=[f"Pinned email {RAW_EMAIL}."],
recent_turns=[
{"speaker": "counselor", "text": f"Previous raw RRN {RAW_RRN}."},
],
)
blob = _message_blob(ctx.messages)
for raw in RAW_VALUES:
self.assertNotIn(raw, blob)
for masked in MASK_VALUES:
self.assertIn(masked, blob)
async def test_run_turn_generate_sends_only_masked_engine_payload(self) -> None:
ctx = _prepare_context()
engine = CaptureGenerateEngine()
await orchestrator.run_turn_generate(ctx, engine) # type: ignore[arg-type]
self.assertIsNotNone(engine.request)
self.assertIsNotNone(engine.payload)
_assert_no_raw_pii(self, engine.request.messages)
_assert_no_raw_pii(self, engine.payload)
_assert_masked_pii_present(self, engine.request.messages)
_assert_masked_pii_present(self, engine.payload)
self.assertEqual(ctx.learner_text_raw, RAW_TEXT)
async def test_run_turn_stream_sends_only_masked_engine_payload(self) -> None:
ctx = _prepare_context()
engine = CaptureStreamEngine()
events = [
event
async for event in orchestrator.run_turn_stream(ctx, engine) # type: ignore[arg-type]
]
self.assertEqual([event.event for event in events], ["token", "done"])
self.assertIsNotNone(engine.request)
self.assertIsNotNone(engine.payload)
_assert_no_raw_pii(self, engine.request.messages)
_assert_no_raw_pii(self, engine.payload)
_assert_masked_pii_present(self, engine.request.messages)
_assert_masked_pii_present(self, engine.payload)
self.assertEqual(ctx.learner_text_raw, RAW_TEXT)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,498 @@
"""Regression tests for persona approval and faculty review boundaries."""
from __future__ import annotations
import unittest
from typing import Any
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from . import persona_repository
from .deps import Principal, Role
from .persona_repository import PersonaReviewItem
from .routes import personas, sessions
from .services import persona as persona_service
def _principal(role: Role = Role.LEARNER) -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000901",
role=role,
cohort_ids=["cohort-a"] if role == Role.TEACHER else [],
email=f"{role.value}@example.test",
display_name=role.value.title(),
)
def _card_row(
card: persona_service.PersonaCard,
*,
persona_id: str,
status: str,
version: int = 1,
) -> dict[str, Any]:
return {
"persona_id": persona_id,
"code": card.code,
"version": version,
"status": status,
"display_name": card.display_name,
"difficulty": card.difficulty,
"theory_target": list(card.theory_target),
"demographics": dict(card.demographics),
"presenting": dict(card.presenting),
"history": dict(card.history),
"big5": dict(card.big5),
"resistance": dict(card.resistance),
"speech_style": dict(card.speech_style),
"affect_baseline": dict(card.affect_baseline),
"ccd": dict(card.ccd),
"dsm5_dimensional": dict(card.dsm5_dimensional),
"source_provenance": card.source_provenance,
"is_synthetic": card.is_synthetic,
"created_at": "2026-01-01T00:00:00",
"approved_at": "2026-01-02T00:00:00" if status == "approved" else None,
}
class _Acquire:
def __init__(self, conn: "_PersonaCardConn") -> None:
self.conn = conn
async def __aenter__(self) -> "_PersonaCardConn":
return self.conn
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return None
class _PersonaCardConn:
def __init__(self, rows: list[dict[str, Any]]) -> None:
self.rows = rows
self.fetch_calls: list[tuple[str, tuple[Any, ...]]] = []
self.fetchrow_calls: list[tuple[str, tuple[Any, ...]]] = []
self.execute_calls: list[tuple[str, tuple[Any, ...]]] = []
async def fetch(self, query: str, *args: Any) -> list[dict[str, Any]]:
self.fetch_calls.append((query, args))
return self._filter_rows(query, args)
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
self.fetchrow_calls.append((query, args))
if "UPDATE app.persona_card" in query:
persona_id = str(args[0])
next_status = str(args[1])
approved_by = args[2]
for row in self.rows:
if row["persona_id"] != persona_id or row["status"] not in {"draft", "review"}:
continue
row["status"] = next_status
row["approved_by"] = approved_by
row["approved_at"] = "2026-01-03T00:00:00" if next_status == "approved" else None
return row
return None
rows = self._filter_rows(query, args)
code = str(args[0]).upper() if args else ""
matches = [row for row in rows if str(row["code"]).upper() == code]
matches.sort(key=lambda row: int(row["version"]), reverse=True)
return matches[0] if matches else None
async def execute(self, query: str, *args: Any) -> str:
self.execute_calls.append((query, args))
return "INSERT 0 1"
def _filter_rows(self, query: str, args: tuple[Any, ...]) -> list[dict[str, Any]]:
if "WHERE status = 'approved'" in query:
return [row for row in self.rows if row["status"] == "approved"]
if "status = ANY($1::text[])" in query:
statuses = {str(status) for status in args[0]}
return [row for row in self.rows if row["status"] in statuses]
return list(self.rows)
class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
async def test_catalog_repository_lists_only_approved_personas(self) -> None:
conn = _PersonaCardConn(
[
_card_row(
persona_service.P1,
persona_id="00000000-0000-0000-0000-000000000001",
status="approved",
),
_card_row(
persona_service.P2,
persona_id="00000000-0000-0000-0000-000000000002",
status="draft",
),
_card_row(
persona_service.P3,
persona_id="00000000-0000-0000-0000-000000000003",
status="review",
),
]
)
acquire_calls: list[dict[str, Any]] = []
def fake_acquire(**kwargs: Any) -> _Acquire:
acquire_calls.append(kwargs)
return _Acquire(conn)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", fake_acquire),
):
result = await persona_repository.list_approved_personas()
self.assertEqual([entry.card.code for entry in result], ["P1"])
self.assertEqual(acquire_calls, [{"ai_context": True}])
self.assertIn("WHERE status = 'approved'", conn.fetch_calls[0][0])
async def test_start_lookup_ignores_draft_or_review_persona_versions(self) -> None:
conn = _PersonaCardConn(
[
_card_row(
persona_service.P2,
persona_id="00000000-0000-0000-0000-000000000102",
status="draft",
version=2,
),
_card_row(
persona_service.P2,
persona_id="00000000-0000-0000-0000-000000000101",
status="review",
version=1,
),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
result = await persona_repository.get_approved_persona("p2")
self.assertIsNone(result)
query, args = conn.fetchrow_calls[0]
self.assertIn("WHERE status = 'approved'", query)
self.assertEqual(args, ("P2",))
async def test_session_start_rejects_persona_without_approved_catalog_entry(self) -> None:
principal = _principal(Role.LEARNER)
with (
patch.object(sessions, "get_catalog_persona", AsyncMock(return_value=None)) as get_persona,
patch.object(
sessions.session_persistence,
"create_session",
AsyncMock(side_effect=AssertionError("draft persona must not start a session")),
) as create_session,
):
with self.assertRaises(HTTPException) as caught:
await sessions.start_session(
sessions.SessionStartRequest(persona_code="P2"),
principal,
)
self.assertEqual(caught.exception.status_code, 404)
self.assertIn("unknown persona P2", caught.exception.detail)
get_persona.assert_awaited_once_with("P2")
create_session.assert_not_awaited()
class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
async def test_review_queue_repository_fetches_draft_and_review_for_teacher(self) -> None:
conn = _PersonaCardConn(
[
_card_row(
persona_service.P1,
persona_id="00000000-0000-0000-0000-000000000201",
status="approved",
),
_card_row(
persona_service.P2,
persona_id="00000000-0000-0000-0000-000000000202",
status="draft",
),
_card_row(
persona_service.P3,
persona_id="00000000-0000-0000-0000-000000000203",
status="review",
),
]
)
acquire_calls: list[dict[str, Any]] = []
def fake_acquire(**kwargs: Any) -> _Acquire:
acquire_calls.append(kwargs)
return _Acquire(conn)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", fake_acquire),
):
queue = await persona_repository.list_persona_review_queue(role="teacher")
self.assertEqual([item.code for item in queue], ["P2", "P3"])
self.assertEqual([item.status for item in queue], ["draft", "review"])
self.assertEqual(acquire_calls, [{"role": "teacher"}])
query, args = conn.fetch_calls[0]
self.assertIn("status = ANY($1::text[])", query)
self.assertEqual(args, (["draft", "review"],))
async def test_review_queue_repository_rejects_learner_role(self) -> None:
with patch.object(
persona_repository,
"get_pool",
side_effect=AssertionError("learner must be rejected before DB access"),
):
with self.assertRaises(ValueError):
await persona_repository.list_persona_review_queue(role="learner")
async def test_learner_cannot_call_review_route(self) -> None:
with patch.object(
personas,
"list_persona_review_queue",
AsyncMock(side_effect=AssertionError("learner must not reach review repository")),
) as review_queue:
with self.assertRaises(HTTPException) as caught:
await personas.list_persona_reviews(_principal(Role.LEARNER))
self.assertEqual(caught.exception.status_code, 403)
review_queue.assert_not_awaited()
async def test_teacher_review_route_returns_state_metadata(self) -> None:
review_items = [
PersonaReviewItem(
persona_id="00000000-0000-0000-0000-000000000302",
code="P2",
version=2,
status="draft",
display_name="Draft Persona",
difficulty="moderate",
theory_target=["humanistic"],
source_provenance="faculty import",
is_synthetic=True,
created_at="2026-01-01T00:00:00",
approved_at=None,
),
PersonaReviewItem(
persona_id="00000000-0000-0000-0000-000000000303",
code="P3",
version=1,
status="review",
display_name="Review Persona",
difficulty="hard",
theory_target=["cbt"],
source_provenance="faculty import",
is_synthetic=True,
created_at="2026-01-02T00:00:00",
approved_at=None,
),
]
with patch.object(
personas,
"list_persona_review_queue",
AsyncMock(return_value=review_items),
) as review_queue:
response = await personas.list_persona_reviews(_principal(Role.TEACHER))
review_queue.assert_awaited_once_with(role="teacher")
self.assertEqual([item.code for item in response], ["P2", "P3"])
self.assertEqual([item.status for item in response], ["draft", "review"])
self.assertEqual(response[0].version, 2)
self.assertIsNone(response[0].approved_at)
async def test_admin_review_route_uses_admin_db_role(self) -> None:
with patch.object(
personas,
"list_persona_review_queue",
AsyncMock(return_value=[]),
) as review_queue:
response = await personas.list_persona_reviews(_principal(Role.ADMIN))
self.assertEqual(response, [])
review_queue.assert_awaited_once_with(role="admin")
async def test_teacher_approves_review_persona_and_audits_decision(self) -> None:
reviewer_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000401"
conn = _PersonaCardConn(
[
_card_row(
persona_service.P2,
persona_id=persona_id,
status="review",
version=2,
),
]
)
acquire_calls: list[dict[str, Any]] = []
def fake_acquire(**kwargs: Any) -> _Acquire:
acquire_calls.append(kwargs)
return _Acquire(conn)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", fake_acquire),
):
updated = await persona_repository.update_persona_review_status(
persona_id=persona_id,
action="approve",
reviewer_id=reviewer_id,
role="teacher",
)
self.assertIsNotNone(updated)
assert updated is not None
self.assertEqual(updated.status, "approved")
self.assertEqual(updated.approved_at, "2026-01-03T00:00:00")
self.assertEqual(acquire_calls, [{"role": "teacher", "user_id": reviewer_id}])
update_query, update_args = conn.fetchrow_calls[0]
self.assertIn("UPDATE app.persona_card", update_query)
self.assertIn("status IN ('draft', 'review')", update_query)
self.assertEqual(update_args, (persona_id, "approved", reviewer_id))
audit_query, audit_args = conn.execute_calls[0]
self.assertIn("INSERT INTO audit.audit_log", audit_query)
self.assertEqual(audit_args[1], "persona_approve")
self.assertEqual(audit_args[2], "persona_card")
self.assertEqual(audit_args[3], persona_id)
self.assertEqual(audit_args[4]["next_status"], "approved")
async def test_reject_review_persona_returns_it_to_draft_and_audits(self) -> None:
reviewer_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000402"
conn = _PersonaCardConn(
[
_card_row(
persona_service.P3,
persona_id=persona_id,
status="review",
version=1,
),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
updated = await persona_repository.update_persona_review_status(
persona_id=persona_id,
action="reject",
reviewer_id=reviewer_id,
role="admin",
)
self.assertIsNotNone(updated)
assert updated is not None
self.assertEqual(updated.status, "draft")
self.assertIsNone(updated.approved_at)
_, update_args = conn.fetchrow_calls[0]
self.assertEqual(update_args, (persona_id, "draft", None))
_, audit_args = conn.execute_calls[0]
self.assertEqual(audit_args[1], "persona_reject")
self.assertEqual(audit_args[4]["next_status"], "draft")
async def test_review_update_ignores_already_approved_persona(self) -> None:
conn = _PersonaCardConn(
[
_card_row(
persona_service.P1,
persona_id="00000000-0000-0000-0000-000000000403",
status="approved",
),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
updated = await persona_repository.update_persona_review_status(
persona_id="00000000-0000-0000-0000-000000000403",
action="approve",
reviewer_id="00000000-0000-0000-0000-000000000901",
role="teacher",
)
self.assertIsNone(updated)
self.assertEqual(conn.execute_calls, [])
async def test_learner_cannot_call_review_decision_route(self) -> None:
with patch.object(
personas,
"update_persona_review_status",
AsyncMock(side_effect=AssertionError("learner must not reach review update")),
) as update_review:
with self.assertRaises(HTTPException) as caught:
await personas.decide_persona_review(
"00000000-0000-0000-0000-000000000404",
personas.PersonaReviewDecisionRequest(action="approve"),
_principal(Role.LEARNER),
)
self.assertEqual(caught.exception.status_code, 403)
update_review.assert_not_awaited()
async def test_review_decision_route_returns_404_for_non_pending_persona(self) -> None:
with patch.object(
personas,
"update_persona_review_status",
AsyncMock(return_value=None),
) as update_review:
with self.assertRaises(HTTPException) as caught:
await personas.decide_persona_review(
"00000000-0000-0000-0000-000000000405",
personas.PersonaReviewDecisionRequest(action="approve"),
_principal(Role.TEACHER),
)
self.assertEqual(caught.exception.status_code, 404)
update_review.assert_awaited_once_with(
persona_id="00000000-0000-0000-0000-000000000405",
action="approve",
reviewer_id="00000000-0000-0000-0000-000000000901",
role="teacher",
)
async def test_review_decision_route_returns_updated_summary(self) -> None:
updated_item = PersonaReviewItem(
persona_id="00000000-0000-0000-0000-000000000406",
code="P2",
version=3,
status="approved",
display_name="Approved Persona",
difficulty="moderate",
theory_target=["humanistic"],
source_provenance="faculty import",
is_synthetic=True,
created_at="2026-01-01T00:00:00",
approved_at="2026-01-03T00:00:00",
)
with patch.object(
personas,
"update_persona_review_status",
AsyncMock(return_value=updated_item),
) as update_review:
response = await personas.decide_persona_review(
"00000000-0000-0000-0000-000000000406",
personas.PersonaReviewDecisionRequest(action="approve"),
_principal(Role.ADMIN),
)
self.assertEqual(response.status, "approved")
self.assertEqual(response.approved_at, "2026-01-03T00:00:00")
update_review.assert_awaited_once_with(
persona_id="00000000-0000-0000-0000-000000000406",
action="approve",
reviewer_id="00000000-0000-0000-0000-000000000901",
role="admin",
)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,440 @@
"""Focused RBAC, IDOR, and session-read audit regression tests."""
from __future__ import annotations
import unittest
from typing import Any
from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from . import session_persistence
from .deps import Principal, Role
from .routes import sessions
from .services import persona as persona_service, state_machine
from .store import InProcSession, TurnRecord, store
def _principal(
*,
user_id: str,
role: Role = Role.LEARNER,
) -> Principal:
return Principal(
user_id=user_id,
role=role,
cohort_ids=["cohort-a"] if role == Role.TEACHER else [],
email=f"{role.value}-{user_id[-4:]}@example.test",
display_name=f"{role.value.title()} {user_id[-4:]}",
)
def _session(
*,
session_id: str,
learner_id: str,
) -> InProcSession:
card = persona_service.P1
return InProcSession(
session_id=session_id,
case_id=f"case-{session_id[-12:]}",
learner_id=learner_id,
persona_code=card.code,
theory_mode="humanistic",
persona=card,
state=state_machine.SessionState(
resistance=card.base_resistance(),
ideation_stage=card.ideation_baseline(),
),
)
def _turn(
*,
seq: int,
speaker: str,
text: str,
visible_to: tuple[str, ...] = ("client", "counselor", "evaluator"),
) -> TurnRecord:
return TurnRecord(
turn_seq=seq,
speaker=speaker,
stage="rapport",
text=text,
text_masked=text,
created_at=1_800_000_000.0 + seq,
visible_to=visible_to,
)
class _Acquire:
def __init__(self, conn: "_FakeConn") -> None:
self.conn = conn
async def __aenter__(self) -> "_FakeConn":
return self.conn
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
return None
class _FakeConn:
def __init__(
self,
*,
fetchrow_results: list[Any] | None = None,
fetch_results: list[list[Any]] | None = None,
) -> None:
self.fetchrow_results = list(fetchrow_results or [])
self.fetch_results = list(fetch_results or [])
self.executed: list[tuple[str, tuple[Any, ...]]] = []
async def fetchrow(self, *args: Any, **kwargs: Any) -> Any:
if not self.fetchrow_results:
raise AssertionError("unexpected fetchrow")
return self.fetchrow_results.pop(0)
async def fetch(self, *args: Any, **kwargs: Any) -> list[Any]:
if not self.fetch_results:
raise AssertionError("unexpected fetch")
return self.fetch_results.pop(0)
async def execute(self, query: str, *args: Any) -> str:
self.executed.append((query, args))
return "INSERT 0 1"
def _audit_calls(conn: _FakeConn) -> list[tuple[str, tuple[Any, ...]]]:
return [
call
for call in conn.executed
if "INSERT INTO audit.audit_log" in call[0]
]
class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
store._sessions.clear()
async def asyncTearDown(self) -> None:
store._sessions.clear()
async def test_get_session_detail_rejects_other_learner_session_id(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000101",
)
intruder = _principal(
user_id="00000000-0000-0000-0000-000000000202",
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000a101",
learner_id=owner.user_id,
)
sess.turns.append(
_turn(
seq=1,
speaker="counselor",
text="owner-visible turn must not grant access",
visible_to=("counselor", "evaluator"),
)
)
store.put(sess)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=None),
),
patch.object(sessions, "runtime_fallback_allowed", return_value=True),
patch.object(
sessions,
"_review_ready",
AsyncMock(side_effect=AssertionError("review lookup should not run")),
) as review_ready,
):
with self.assertRaises(HTTPException) as caught:
await sessions.get_session_detail(sess.session_id, intruder)
self.assertEqual(caught.exception.status_code, 403)
self.assertIn("does not belong", caught.exception.detail)
review_ready.assert_not_awaited()
async def test_session_detail_filters_evaluator_only_turns(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000111",
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000e111",
learner_id=owner.user_id,
)
sess.turns.extend(
[
_turn(seq=1, speaker="counselor", text="learner normal turn"),
_turn(seq=2, speaker="client", text="client normal turn"),
_turn(
seq=3,
speaker="counselor",
text="SECRET_EVALUATOR_ONLY_DETAIL",
visible_to=("evaluator",),
),
]
)
store.put(sess)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=None),
),
patch.object(sessions, "runtime_fallback_allowed", return_value=True),
patch.object(sessions, "_review_ready", AsyncMock(return_value=False)),
):
response = await sessions.get_session_detail(sess.session_id, owner)
self.assertEqual([turn.text for turn in response.turns], ["learner normal turn", "client normal turn"])
self.assertEqual([turn.speaker for turn in response.turns], ["learner", "client"])
async def test_session_review_filters_evaluator_only_turns_and_payload(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000112",
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000e112",
learner_id=owner.user_id,
)
sess.ended = True
sess.ended_at = 1_800_000_120.0
sess.turns.extend(
[
_turn(seq=1, speaker="counselor", text="visible learner review turn"),
_turn(seq=2, speaker="client", text="visible client review turn"),
_turn(
seq=3,
speaker="client",
text="SECRET_EVALUATOR_ONLY_REVIEW",
visible_to=("evaluator",),
),
]
)
store.put(sess)
evaluation_record = {
"status": "ready",
"payload": {
"strengths": ["SECRET_EVALUATOR_ONLY_REVIEW"],
"improvements": ["SECRET_EVALUATOR_ONLY_REVIEW"],
"supervisor_rationale": "SECRET_EVALUATOR_ONLY_REVIEW",
"alternative_utterances": ["SECRET_EVALUATOR_ONLY_REVIEW"],
},
}
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=None),
),
patch.object(sessions, "runtime_fallback_allowed", return_value=True),
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(evaluation_record, True)),
),
):
response = await sessions.get_session_review(sess.session_id, owner)
self.assertEqual([turn.text for turn in response.turns], ["visible learner review turn", "visible client review turn"])
self.assertFalse(response.reviewReady)
rendered = " ".join(
[
response.summary,
response.clientFeedback or "",
response.nextLine or "",
*[turn.text for turn in response.turns],
*[point.body for point in response.goodMoments],
*[point.body for point in response.growthPoints],
]
)
self.assertNotIn("SECRET_EVALUATOR_ONLY_REVIEW", rendered)
async def test_submit_turn_sends_only_client_visible_history_to_engine(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000113",
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000e113",
learner_id=owner.user_id,
)
sess.turns.extend(
[
_turn(seq=1, speaker="counselor", text="client-visible history"),
_turn(
seq=2,
speaker="client",
text="SECRET_EVALUATOR_ONLY_ENGINE_CONTEXT",
visible_to=("evaluator",),
),
]
)
store.put(sess)
captured_recent_turns: list[dict[str, str]] | None = None
async def successful_turn(ctx, engine, **kwargs):
nonlocal captured_recent_turns
captured_recent_turns = list(ctx.recent_turns)
assert ctx.state_after is not None
return sessions.orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply="client reply",
safety_flagged=False,
state_after=ctx.state_after,
)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=None),
),
patch.object(sessions, "runtime_fallback_allowed", return_value=True),
patch.object(sessions.orchestrator, "run_turn_generate", successful_turn),
):
await sessions.submit_turn(
sess.session_id,
sessions.TurnRequest(text="new learner turn"),
owner,
)
self.assertEqual(captured_recent_turns, [{"speaker": "counselor", "text": "client-visible history"}])
self.assertNotIn(
"SECRET_EVALUATOR_ONLY_ENGINE_CONTEXT",
" ".join(turn["text"] for turn in captured_recent_turns or []),
)
async def test_learner_session_list_filters_other_runtime_sessions(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000303",
)
other = _principal(
user_id="00000000-0000-0000-0000-000000000404",
)
owned_session = _session(
session_id="00000000-0000-0000-0000-00000000b303",
learner_id=owner.user_id,
)
other_session = _session(
session_id="00000000-0000-0000-0000-00000000b404",
learner_id=other.user_id,
)
store.put(owned_session)
store.put(other_session)
with (
patch.object(
sessions.session_persistence,
"list_sessions",
AsyncMock(return_value=([], False)),
),
patch.object(sessions, "require_runtime_fallback_allowed", return_value=None),
patch.object(sessions, "_review_ready", AsyncMock(return_value=False)),
):
response = await sessions.list_learner_sessions(owner)
self.assertEqual(response.source, "runtime")
self.assertEqual([item.session_id for item in response.sessions], [owned_session.session_id])
class TeacherAdminAuditTest(unittest.IsolatedAsyncioTestCase):
async def test_teacher_list_sessions_inserts_read_audit_log(self) -> None:
teacher = _principal(
user_id="00000000-0000-0000-0000-000000000505",
role=Role.TEACHER,
)
session_id = "00000000-0000-0000-0000-00000000c505"
row = {"id": session_id}
sess = _session(
session_id=session_id,
learner_id="00000000-0000-0000-0000-000000000606",
)
conn = _FakeConn(
fetchrow_results=[None],
fetch_results=[[row], []],
)
acquire_calls: list[dict[str, Any]] = []
def fake_acquire(**kwargs: Any) -> _Acquire:
acquire_calls.append(kwargs)
return _Acquire(conn)
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", fake_acquire),
patch.object(session_persistence, "_session_from_rows", return_value=sess),
):
found, durable = await session_persistence.list_sessions(teacher)
self.assertTrue(durable)
self.assertEqual(found, [sess])
self.assertEqual(acquire_calls[0]["role"], "teacher")
audit = _audit_calls(conn)
self.assertEqual(len(audit), 1)
_, args = audit[0]
self.assertEqual(args[0], teacher.user_id)
self.assertEqual(args[1], "read_session")
self.assertEqual(args[2], "session_list")
self.assertEqual(args[3], "sessions")
self.assertEqual(args[4]["access"], "list_sessions")
self.assertEqual(args[4]["role"], "teacher")
self.assertEqual(args[4]["result_count"], 1)
async def test_admin_load_session_inserts_read_audit_log(self) -> None:
admin = _principal(
user_id="00000000-0000-0000-0000-000000000707",
role=Role.ADMIN,
)
session_id = "00000000-0000-0000-0000-00000000d707"
learner_id = "00000000-0000-0000-0000-000000000808"
sess = _session(session_id=session_id, learner_id=learner_id)
conn = _FakeConn(
fetchrow_results=[
{"id": session_id, "ended_at": None},
None,
],
fetch_results=[[]],
)
acquire_calls: list[dict[str, Any]] = []
def fake_acquire(**kwargs: Any) -> _Acquire:
acquire_calls.append(kwargs)
return _Acquire(conn)
with (
patch.object(session_persistence, "get_pool", return_value=object()),
patch.object(session_persistence, "acquire", fake_acquire),
patch.object(session_persistence, "_session_from_rows", return_value=sess),
):
found = await session_persistence.load_session(
session_id,
admin,
allow_ended=True,
)
self.assertEqual(found, sess)
self.assertEqual(acquire_calls[0]["role"], "admin")
audit = _audit_calls(conn)
self.assertEqual(len(audit), 1)
_, args = audit[0]
self.assertEqual(args[0], admin.user_id)
self.assertEqual(args[1], "read_session")
self.assertEqual(args[2], "session")
self.assertEqual(args[3], session_id)
self.assertEqual(args[4]["access"], "load_session")
self.assertEqual(args[4]["role"], "admin")
self.assertEqual(args[4]["learner_id"], learner_id)
if __name__ == "__main__":
unittest.main()

View file

@ -264,6 +264,42 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(cfg.environment, "staging")
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
def test_non_dev_accepts_explicit_local_vite_cors_ports(self) -> None:
cfg = Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="prod-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
cors_origins=[
"https://vignette.chanpaca.net",
"http://localhost:5170",
"http://127.0.0.1:5180",
],
)
self.assertIn("http://localhost:5170", cfg.cors_origins)
self.assertIn("http://127.0.0.1:5180", cfg.cors_origins)
def test_non_dev_rejects_unscoped_local_cors_ports(self) -> None:
with self.assertRaises(ValueError) as caught:
Settings(
environment="prod",
auth_dev_login_enabled=False,
auto_seed_personas=False,
allow_seed_persona_fallback=False,
session_secret="prod-secret-change-me",
oauth_google_client_id="google-client-id",
oauth_google_client_secret="google-client-secret",
frontend_base_url="https://vignette.chanpaca.net",
cors_origins=["http://localhost:5181"],
)
self.assertIn("CORS_ORIGINS", str(caught.exception))
if __name__ == "__main__":
unittest.main()

View file

@ -10,7 +10,7 @@ from .engine_client import EngineError
from .routes import sessions
from .routes import voice as voice_routes
from .services import orchestrator, persona as persona_service, state_machine
from .services.voice import VoicePreset
from .services.voice import TTSChunk, TranscriptResult, VoicePreset
from .store import InProcSession, store
@ -81,6 +81,152 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(caught.exception.status_code, 503)
self.assertEqual(sess.turns, [])
async def test_generate_turn_persists_client_engine_telemetry(self) -> None:
principal = _principal()
sess = _session(principal)
async def successful_turn(ctx, engine, **kwargs):
assert ctx.state_after is not None
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply="괜찮아요. 천천히 말해볼게요.",
safety_flagged=False,
state_after=ctx.state_after,
llm_provider="claude_cli",
model="gateway-default",
tokens_in=17,
tokens_out=23,
cost_usd=0.012345,
)
with patch.object(sessions.orchestrator, "run_turn_generate", successful_turn):
response = await sessions.submit_turn(
sess.session_id,
sessions.TurnRequest(text="요즘 많이 힘들었겠어요."),
principal,
)
self.assertEqual(response.client_reply, "괜찮아요. 천천히 말해볼게요.")
self.assertEqual(len(sess.turns), 2)
learner_turn, client_turn = sess.turns
self.assertIsNone(learner_turn.llm_provider)
self.assertEqual(client_turn.llm_provider, "claude_cli")
self.assertEqual(client_turn.model, "gateway-default")
self.assertEqual(client_turn.tokens_in, 17)
self.assertEqual(client_turn.tokens_out, 23)
self.assertEqual(client_turn.cost_usd, 0.012345)
async def test_stream_turn_persists_client_engine_telemetry(self) -> None:
principal = _principal()
sess = _session(principal)
async def successful_stream(ctx, engine):
assert ctx.state_after is not None
yield orchestrator.StreamEvent("token", {"text": "괜찮아요."})
yield orchestrator.StreamEvent(
"done",
{
"session_id": ctx.session_id,
"stage": ctx.state_after.stage.value,
"effective_openness": ctx.state_after.effective_openness,
"turn_seq": ctx.state_after.turn_seq,
"safety_flagged": False,
"llm_provider": "claude_cli",
"model": "gateway-default",
"tokens_in": 31,
"tokens_out": 37,
"cost_usd": 0.023456,
},
)
with patch.object(sessions.orchestrator, "run_turn_stream", successful_stream):
response = await sessions.stream_turn(
sess.session_id,
sessions.TurnRequest(text="스트림 성공 발화"),
principal,
)
body = await _consume_event_source(response)
self.assertIn(b"done", body)
self.assertEqual(len(sess.turns), 2)
client_turn = sess.turns[1]
self.assertEqual(client_turn.llm_provider, "claude_cli")
self.assertEqual(client_turn.model, "gateway-default")
self.assertEqual(client_turn.tokens_in, 31)
self.assertEqual(client_turn.tokens_out, 37)
self.assertEqual(client_turn.cost_usd, 0.023456)
async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None:
class FakeStreamEngine:
engine_mode = "claude_cli"
default_model = None
async def stream(self, req):
yield "event: token"
yield '{"ignored":"not data"}'
yield 'data: {"text":"부분 응답"}'
yield "event: done"
yield (
'data: {"provider":"claude_cli","model":"gateway-default",'
'"tokens_in":5,"tokens_out":7,"cost_usd":0.034567}'
)
principal = _principal()
sess = _session(principal)
ctx = orchestrator.prepare_turn(
session_id=sess.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text="게이트웨이 스트림 테스트",
recent_turns=[],
)
events = [
event
async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type]
]
self.assertEqual([event.event for event in events], ["token", "done"])
self.assertEqual(events[0].data["text"], "부분 응답")
self.assertEqual(events[1].data["llm_provider"], "claude_cli")
self.assertEqual(events[1].data["model"], "gateway-default")
self.assertEqual(events[1].data["tokens_in"], 5)
self.assertEqual(events[1].data["tokens_out"], 7)
self.assertEqual(events[1].data["cost_usd"], 0.034567)
async def test_run_turn_stream_treats_gateway_error_event_as_error(self) -> None:
class FakeStreamEngine:
engine_mode = "claude_cli"
default_model = None
async def stream(self, req):
yield "event: token"
yield 'data: {"text":"부분 응답"}'
yield "event: error"
yield 'data: {"detail":"engine unavailable: gateway"}'
principal = _principal()
sess = _session(principal)
ctx = orchestrator.prepare_turn(
session_id=sess.session_id,
case_id=sess.case_id,
card=sess.persona,
state=sess.state,
learner_text="게이트웨이 오류 테스트",
recent_turns=[],
)
events = [
event
async for event in orchestrator.run_turn_stream(ctx, FakeStreamEngine()) # type: ignore[arg-type]
]
self.assertEqual([event.event for event in events], ["token", "error"])
self.assertIn("engine unavailable", events[1].data["detail"])
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
principal = _principal()
sess = _session(principal)
@ -138,6 +284,79 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
)
self.assertEqual(sess.turns, [])
async def test_voice_audio_turn_persists_paralinguistic_metadata(self) -> None:
class FakeWebSocket:
def __init__(self) -> None:
self.messages: list[dict[str, object]] = []
self.binary: list[bytes] = []
self.client_state = voice_routes.WebSocketState.CONNECTED
async def send_text(self, data: str) -> None:
import json
self.messages.append(json.loads(data))
async def send_bytes(self, data: bytes) -> None:
self.binary.append(data)
async def successful_turn(ctx, engine, **kwargs):
assert ctx.state_after is not None
return orchestrator.TurnResult(
turn_seq=ctx.state_after.turn_seq,
stage=ctx.state_after.stage.value,
effective_openness=ctx.state_after.effective_openness,
client_reply="천천히 말해줘서 고마워요.",
safety_flagged=False,
state_after=ctx.state_after,
llm_provider="claude_cli",
model="gateway-default",
tokens_in=11,
tokens_out=13,
cost_usd=0.0012,
)
async def fake_synthesize_stream(text, voice_preset):
yield TTSChunk(audio=b"tts-audio")
principal = _principal()
sess = _session(principal)
websocket = FakeWebSocket()
audio = b"\x00\x80" * 1600
with patch.object(
voice_routes.voice_service,
"transcribe",
AsyncMock(return_value=TranscriptResult(text="오늘은 좀 힘들었어요.", duration=2.0)),
), patch.object(
voice_routes.orchestrator,
"run_turn_generate",
successful_turn,
), patch.object(
voice_routes.voice_service,
"synthesize_stream",
fake_synthesize_stream,
):
await voice_routes._handle_utterance(
websocket, # type: ignore[arg-type]
session_id=sess.session_id,
principal=principal,
voice_preset=VoicePreset(preset="neutral", openai_voice="sage"),
audio=audio,
fmt="webm",
silence_ms=1234,
barge_in=True,
)
self.assertEqual(len(sess.turns), 2)
learner_turn, client_turn = sess.turns
self.assertTrue(str(learner_turn.audio_ref).startswith("voice:webm:sha256:"))
self.assertEqual(learner_turn.silence_ms, 1234)
self.assertGreater(learner_turn.speech_rate or 0, 0)
self.assertTrue(learner_turn.barge_in)
self.assertIsNone(client_turn.audio_ref)
self.assertEqual(client_turn.llm_provider, "claude_cli")
self.assertTrue(any(message.get("type") == "tts_end" for message in websocket.messages))
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,73 @@
"""Regression tests for the deterministic resistance engine."""
from __future__ import annotations
import unittest
from .services import state_machine
from .services.persona import P1
EMPATHIC_UTTERANCES = [
"얼마나 힘들었는지 마음이 느껴져요. 어떤 순간이 제일 버거웠나요?",
"그런 마음을 꺼내는 것 자체가 쉽지 않았을 것 같아요. 더 말해줘도 괜찮아요.",
"잠도 잘 못 자고 학교도 버거웠다면 하루가 길게 느껴졌겠어요.",
"지금은 해결책보다 그 마음을 천천히 이해하는 게 먼저인 것 같아요.",
"그 시간을 버텨온 마음을 함께 살펴보고 싶어요. 무엇부터 이야기해볼까요?",
]
ADVICE_JUMP_UTTERANCES = [
"그냥 학교는 가야 해요. 노력하면 하면 돼요. 왜 안 하죠?",
"그건 잘못 생각하는 거예요. 원래 다 힘들어요.",
"당연히 엄마 말을 들어야죠. 하지 마세요.",
"내 생각엔 그냥 계획표를 만들면 돼요.",
"그러니까 더 노력해야 해요. 왜 안 바꾸나요?",
]
def _initial_p1_state() -> state_machine.SessionState:
return state_machine.init_state(
params=P1.openness_params(),
)
def _run_curve(utterances: list[str]) -> list[state_machine.SessionState]:
state = _initial_p1_state()
curve: list[state_machine.SessionState] = []
for utterance in utterances:
signal = state_machine.estimate_rapport_signal(utterance)
state = state_machine.evolve(
state,
rapport_signal=signal,
unlock_rate=P1.unlock_rate(),
decay_floor=P1.decay_floor(),
)
curve.append(state)
return curve
class ResistanceEngineTest(unittest.TestCase):
def test_empathy_opens_p1_while_advice_jump_closes_it(self) -> None:
empathy_curve = _run_curve(EMPATHIC_UTTERANCES)
advice_curve = _run_curve(ADVICE_JUMP_UTTERANCES)
empathy_final = empathy_curve[-1]
advice_final = advice_curve[-1]
self.assertGreater(empathy_final.rapport_credit, advice_final.rapport_credit)
self.assertLess(empathy_final.resistance, advice_final.resistance)
self.assertGreater(empathy_final.effective_openness, advice_final.effective_openness)
self.assertEqual(empathy_final.stage, state_machine.Stage.EXPLORE)
self.assertEqual(advice_final.stage, state_machine.Stage.RAPPORT)
self.assertGreater(empathy_final.effective_openness, 0.1)
self.assertEqual(advice_final.effective_openness, 0.0)
def test_advice_jump_never_advances_stage_after_five_turns(self) -> None:
advice_curve = _run_curve(ADVICE_JUMP_UTTERANCES)
self.assertTrue(all(state.stage is state_machine.Stage.RAPPORT for state in advice_curve))
self.assertTrue(all(state.rapport_credit == 0 for state in advice_curve))
self.assertGreaterEqual(advice_curve[-1].resistance, 0.95)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,223 @@
"""Deterministic tests for voice preset, TTS payload, and EOT helpers."""
from __future__ import annotations
import unittest
from .services.voice import (
DEFAULT_OPENAI_VOICE,
EOT_SILENCE_THRESHOLD_MS,
TTS_ENDPOINT,
TTS_MODEL,
TTS_MODEL_FALLBACK,
VoicePreset,
VoiceService,
assess_end_of_turn,
build_tts_payload,
resolve_voice,
)
class VoicePresetResolutionTest(unittest.TestCase):
def test_persona_codes_resolve_distinct_voice_presets(self) -> None:
expected = {
"P1": ("soft-young-fem", "coral", 0.96),
"P2": ("calm-adult-male", "ash", 1.0),
"P3": ("warm-adult-fem", "shimmer", 0.98),
}
resolved = {code: resolve_voice(persona_code=code) for code in expected}
self.assertEqual(
{voice.openai_voice for voice in resolved.values()},
{"coral", "ash", "shimmer"},
)
for code, (preset, openai_voice, rate) in expected.items():
with self.subTest(code=code):
voice = resolved[code]
self.assertEqual(voice.preset, preset)
self.assertEqual(voice.openai_voice, openai_voice)
self.assertAlmostEqual(voice.rate, rate)
self.assertEqual(resolve_voice(persona_code="p2").preset, "calm-adult-male")
def test_invalid_explicit_preset_falls_back_to_default_openai_voice(self) -> None:
voice = resolve_voice(persona_code="P1", preset="not-a-real-preset")
self.assertEqual(voice.preset, "not-a-real-preset")
self.assertEqual(voice.openai_voice, DEFAULT_OPENAI_VOICE)
self.assertEqual(voice.rate, 1.0)
class TTSPayloadTest(unittest.TestCase):
def test_payload_contains_openai_tts_fields_and_clamps_high_speed(self) -> None:
voice = VoicePreset(
preset="soft-young-fem",
openai_voice="coral",
rate=9.5,
instructions="Speak gently with low intensity.",
)
payload = build_tts_payload("Client reply", voice)
self.assertEqual(
payload,
{
"model": TTS_MODEL,
"voice": "coral",
"input": "Client reply",
"response_format": "mp3",
"speed": 4.0,
"instructions": "Speak gently with low intensity.",
},
)
def test_payload_omits_instructions_for_fallback_model_and_clamps_low_speed(self) -> None:
voice = VoicePreset(
preset="neutral",
openai_voice="sage",
rate=0.1,
instructions="This should not be sent to tts-1.",
)
payload = build_tts_payload(
"Fallback reply",
voice,
model=TTS_MODEL_FALLBACK,
response_format="opus",
)
self.assertEqual(payload["model"], TTS_MODEL_FALLBACK)
self.assertEqual(payload["voice"], "sage")
self.assertEqual(payload["input"], "Fallback reply")
self.assertEqual(payload["response_format"], "opus")
self.assertEqual(payload["speed"], 0.25)
self.assertNotIn("instructions", payload)
def test_payload_uses_default_speed_for_invalid_rate(self) -> None:
voice = VoicePreset(
preset="neutral",
openai_voice="sage",
rate="fast", # type: ignore[arg-type]
)
payload = build_tts_payload("Client reply", voice)
self.assertEqual(payload["speed"], 1.0)
class _FakeTTSStream:
def __init__(self, chunks: list[bytes]) -> None:
self.status_code = 200
self._chunks = chunks
self.closed = False
async def __aenter__(self) -> "_FakeTTSStream":
return self
async def __aexit__(self, exc_type, exc, tb) -> bool: # noqa: ANN001
return False
async def aclose(self) -> None:
self.closed = True
def raise_for_status(self) -> None:
return None
async def aiter_bytes(self, chunk_size: int = 4096):
self.chunk_size = chunk_size
for chunk in self._chunks:
yield chunk
class _CaptureTTSClient:
def __init__(self, chunks: list[bytes]) -> None:
self.chunks = chunks
self.calls: list[tuple[str, str, dict[str, object]]] = []
def stream(self, method: str, endpoint: str, *, json: dict[str, object]) -> _FakeTTSStream:
self.calls.append((method, endpoint, dict(json)))
return _FakeTTSStream(self.chunks)
class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase):
async def test_synthesize_stream_uses_payload_with_fake_client(self) -> None:
client = _CaptureTTSClient([b"\x80\x80", b"\xff\x00"])
service = VoiceService(api_key="test-key")
service._client = client # type: ignore[assignment]
voice = VoicePreset(
preset="neutral",
openai_voice="sage",
rate=0.1,
instructions="Keep the tone grounded.",
)
chunks = [
chunk
async for chunk in service.synthesize_stream(
"Spoken client reply",
voice,
response_format="opus",
)
]
self.assertEqual(len(client.calls), 1)
method, endpoint, payload = client.calls[0]
self.assertEqual(method, "POST")
self.assertEqual(endpoint, TTS_ENDPOINT)
self.assertEqual(payload["model"], TTS_MODEL)
self.assertEqual(payload["voice"], "sage")
self.assertEqual(payload["input"], "Spoken client reply")
self.assertEqual(payload["response_format"], "opus")
self.assertEqual(payload["speed"], 0.25)
self.assertEqual(payload["instructions"], "Keep the tone grounded.")
self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"])
class EndOfTurnDecisionTest(unittest.TestCase):
def test_end_of_turn_requires_silence_threshold(self) -> None:
pending = assess_end_of_turn(
transcript_text="I am still talking",
transcript_final=True,
silence_ms=EOT_SILENCE_THRESHOLD_MS - 1,
)
ready = assess_end_of_turn(
transcript_text="I am done",
transcript_final=True,
silence_ms=EOT_SILENCE_THRESHOLD_MS,
)
self.assertFalse(pending.ready)
self.assertTrue(pending.transcript_ready)
self.assertFalse(pending.silence_ready)
self.assertEqual(pending.reason, "silence_threshold_pending")
self.assertTrue(ready.ready)
self.assertEqual(ready.reason, "ready")
def test_end_of_turn_waits_for_final_transcript_even_after_silence(self) -> None:
decision = assess_end_of_turn(
transcript_text="Interim transcript",
transcript_final=False,
silence_ms=2500,
)
self.assertFalse(decision.ready)
self.assertFalse(decision.transcript_ready)
self.assertTrue(decision.silence_ready)
self.assertEqual(decision.reason, "final_transcript_pending")
def test_end_of_turn_rejects_empty_final_transcript(self) -> None:
decision = assess_end_of_turn(
transcript_text=" ",
transcript_final=True,
silence_ms=2500,
)
self.assertFalse(decision.ready)
self.assertFalse(decision.transcript_ready)
self.assertTrue(decision.silence_ready)
self.assertEqual(decision.reason, "empty_transcript")
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,294 @@
"""Deterministic WebSocket contract tests for the voice gateway route."""
from __future__ import annotations
import json
import unittest
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from .routes import voice as voice_routes
from .services.voice import VoicePreset
SESSION_ID = "voice-ws-contract-session"
VOICE_PRESET = VoicePreset(preset="neutral", openai_voice="sage")
def _principal(role: Role = Role.LEARNER) -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000201",
role=role,
cohort_ids=[],
email=f"voice-ws-{role.value}@hs.ac.kr",
display_name="Voice WS Contract",
)
def _control(payload: dict[str, object]) -> dict[str, object]:
return {"text": json.dumps(payload)}
def _binary(data: bytes) -> dict[str, object]:
return {"bytes": data}
class FakeWebSocket:
def __init__(self, incoming: list[dict[str, object]] | None = None) -> None:
self._incoming = list(incoming or [])
self.accepted = False
self.client_state = voice_routes.WebSocketState.CONNECTING
self.cookies: dict[str, str] = {}
self.query_params: dict[str, str] = {}
self.sent_json: list[dict[str, object]] = []
self.sent_text: list[str] = []
self.sent_bytes: list[bytes] = []
self.close_codes: list[int] = []
async def accept(self) -> None:
self.accepted = True
self.client_state = voice_routes.WebSocketState.CONNECTED
async def receive(self) -> dict[str, object]:
if self._incoming:
return self._incoming.pop(0)
return {"type": "websocket.disconnect"}
async def send_text(self, data: str) -> None:
self.sent_text.append(data)
self.sent_json.append(json.loads(data))
async def send_bytes(self, data: bytes) -> None:
self.sent_bytes.append(data)
async def close(self, code: int = 1000) -> None:
self.close_codes.append(code)
self.client_state = voice_routes.WebSocketState.DISCONNECTED
class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
def _bind_result(self) -> tuple[str, VoicePreset, None, dict[str, object]]:
return (
SESSION_ID,
VOICE_PRESET,
None,
{"degraded": False, "persona_catalog_source": "session"},
)
async def test_audio_start_binary_chunks_audio_end_ping_close_contract(self) -> None:
websocket = FakeWebSocket(
[
_control({"type": "audio_start", "format": "webm"}),
_binary(b"chunk-one"),
_binary(b"chunk-two"),
_control({"type": "ping"}),
_control(
{
"type": "audio_end",
"format": "webm",
"silence_ms": "450",
"barge_in": "true",
}
),
_control({"type": "close"}),
]
)
handle_utterance = AsyncMock()
with patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
), patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
), patch.object(
voice_routes.voice_service,
"is_available",
return_value=True,
), patch.object(
voice_routes,
"_handle_utterance",
handle_utterance,
), patch.object(
voice_routes,
"_run_turn_and_speak",
AsyncMock(),
), patch.object(
voice_routes.time,
"monotonic",
side_effect=[10.0, 12.0],
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(websocket.accepted)
self.assertEqual(websocket.close_codes, [1000])
self.assertEqual(
[(message.get("type"), message.get("state")) for message in websocket.sent_json],
[("ready", "idle"), ("state", "listening"), ("pong", None)],
)
handle_utterance.assert_awaited_once()
kwargs = handle_utterance.await_args.kwargs
self.assertEqual(kwargs["session_id"], SESSION_ID)
self.assertEqual(kwargs["principal"].user_id, _principal().user_id)
self.assertEqual(kwargs["voice_preset"], VOICE_PRESET)
self.assertEqual(kwargs["audio"], b"chunk-onechunk-two")
self.assertEqual(kwargs["fmt"], "webm")
self.assertEqual(kwargs["audio_started_at"], 10.0)
self.assertEqual(kwargs["audio_ended_at"], 12.0)
self.assertEqual(kwargs["silence_ms"], 450)
self.assertIs(kwargs["barge_in"], True)
async def test_text_turn_strips_text_runs_turn_and_ping_close_still_work(self) -> None:
websocket = FakeWebSocket(
[
_control({"type": "text_turn", "text": " I need help practicing. "}),
_control({"type": "ping"}),
_control({"type": "close"}),
]
)
run_turn = AsyncMock()
handle_utterance = AsyncMock()
with patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
), patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
), patch.object(
voice_routes.voice_service,
"is_available",
return_value=True,
), patch.object(
voice_routes,
"_handle_utterance",
handle_utterance,
), patch.object(
voice_routes,
"_run_turn_and_speak",
run_turn,
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertEqual(websocket.close_codes, [1000])
self.assertEqual(
[(message.get("type"), message.get("state")) for message in websocket.sent_json],
[("ready", "idle"), ("pong", None)],
)
handle_utterance.assert_not_awaited()
run_turn.assert_awaited_once()
kwargs = run_turn.await_args.kwargs
self.assertEqual(kwargs["session_id"], SESSION_ID)
self.assertEqual(kwargs["principal"].user_id, _principal().user_id)
self.assertEqual(kwargs["voice_preset"], VOICE_PRESET)
self.assertEqual(kwargs["learner_text"], "I need help practicing.")
async def test_oversize_binary_audio_reports_error_and_drops_utterance(self) -> None:
websocket = FakeWebSocket(
[
_control({"type": "audio_start", "format": "webm"}),
_binary(b"12345"),
_control({"type": "close"}),
]
)
handle_utterance = AsyncMock()
run_turn = AsyncMock()
with patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal()),
), patch.object(
voice_routes,
"_bind_session",
AsyncMock(return_value=self._bind_result()),
), patch.object(
voice_routes.voice_service,
"is_available",
return_value=True,
), patch.object(
voice_routes,
"_handle_utterance",
handle_utterance,
), patch.object(
voice_routes,
"_run_turn_and_speak",
run_turn,
), patch.object(
voice_routes,
"_MAX_AUDIO_BYTES",
4,
):
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertEqual(websocket.close_codes, [1000])
self.assertEqual(
websocket.sent_json[-1],
{
"type": "error",
"detail": "audio too large; please send a shorter utterance",
},
)
handle_utterance.assert_not_awaited()
run_turn.assert_not_awaited()
async def test_unauthenticated_client_closes_before_session_or_voice_checks(self) -> None:
websocket = FakeWebSocket()
bind_session = AsyncMock()
with patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=None),
), patch.object(
voice_routes,
"_bind_session",
bind_session,
), patch.object(
voice_routes.voice_service,
"is_available",
return_value=True,
) as is_available:
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(websocket.accepted)
self.assertEqual(websocket.close_codes, [voice_routes.WS_CLOSE_UNAUTHORIZED])
self.assertEqual(websocket.sent_json, [{"type": "error", "detail": "not authenticated"}])
bind_session.assert_not_awaited()
is_available.assert_not_called()
async def test_non_learner_client_closes_before_session_or_voice_checks(self) -> None:
websocket = FakeWebSocket()
bind_session = AsyncMock()
with patch.object(
voice_routes,
"_principal_from_websocket",
AsyncMock(return_value=_principal(Role.TEACHER)),
), patch.object(
voice_routes,
"_bind_session",
bind_session,
), patch.object(
voice_routes.voice_service,
"is_available",
return_value=True,
) as is_available:
await voice_routes.voice_ws(websocket) # type: ignore[arg-type]
self.assertTrue(websocket.accepted)
self.assertEqual(websocket.close_codes, [voice_routes.WS_CLOSE_UNAUTHORIZED])
self.assertEqual(
websocket.sent_json,
[{"type": "error", "detail": "only learners can use voice"}],
)
bind_session.assert_not_awaited()
is_available.assert_not_called()
if __name__ == "__main__":
unittest.main()

View file

@ -33,6 +33,10 @@ BASE_ARGS = [
"--output-format", "stream-json",
"--verbose",
"--dangerously-skip-permissions",
# 페르소나 격리: cwd/env/git status/메모리(CLAUDE.md) 등 per-machine 섹션을 시스템프롬프트에서
# 제거 → 내담자 AI가 자신이 개발 환경(Claude Code/Vignette repo) 안에 있음을 알아채 캐릭터를
# 깨는 것을 차단. (시스템프롬프트는 아래에서 --system-prompt 로 페르소나만 '교체' 주입.)
"--exclude-dynamic-system-prompt-sections",
]
@ -69,7 +73,9 @@ class EngineSession:
if FALLBACK_MODEL:
args += ["--fallback-model", FALLBACK_MODEL]
if self.system_prompt:
args += ["--append-system-prompt", self.system_prompt]
# APPEND(기본 코딩 어시스턴트 프롬프트에 덧붙임) 대신 REPLACE → 페르소나가 유일한
# 정체성. 기본 Claude Code 시스템프롬프트가 남으면 내담자가 어시스턴트로 작동/캐릭터 붕괴.
args += ["--system-prompt", self.system_prompt]
self.proc = await asyncio.create_subprocess_exec(
*args,
stdin=asyncio.subprocess.PIPE,
@ -327,7 +333,6 @@ class GwMessage(BaseModel):
class GwGenerateReq(BaseModel):
ai_role: AIRole = "client"
messages: list[GwMessage]
tier: Literal["client", "feedback", "fast"] = "client"
model: Optional[str] = None
max_tokens: int = 1024
temperature: float = 0.7
@ -455,7 +460,14 @@ async def v1_stream(req: GwGenerateReq):
yield f"event: error\ndata: {err}\n\n"
else:
meta = json.dumps(
{"cost_usd": evt.get("cost_usd", 0.0), "turns": evt.get("turns", 0)},
{
"provider": "claude_cli",
"model": s.model or DEFAULT_MODEL or "claude-opus-4-8",
"tokens_in": 0,
"tokens_out": 0,
"cost_usd": evt.get("cost_usd", 0.0),
"turns": evt.get("turns", 0),
},
ensure_ascii=False,
)
yield f"event: done\ndata: {meta}\n\n"

View file

@ -75,7 +75,7 @@ class GatewayModelTest(unittest.TestCase):
self.assertIs(ephemeral, True)
self.assertEqual(session.model, "request-model")
self.assertEqual(_model_arg(captured[0]), "request-model")
self.assertIn("--append-system-prompt", captured[0])
self.assertIn("--system-prompt", captured[0])
finally:
asyncio.run(session.close())
@ -120,6 +120,98 @@ class GatewayModelTest(unittest.TestCase):
finally:
asyncio.run(session.close())
def test_resolve_session_reuses_live_session_id_without_starting_claude(self):
captured, process_patch = _capture_subprocess()
existing = gateway.EngineSession(model=None)
existing.proc = _FakeProcess()
gateway.SESSIONS["sid"] = existing
with (
patch.object(gateway, "DEFAULT_MODEL", "env-default"),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(session_id="sid"), "new system prompt")
)
self.assertIs(session, existing)
self.assertIs(ephemeral, False)
self.assertEqual(captured, [])
def test_resolve_session_creates_fresh_ephemeral_for_missing_session_id(self):
captured, process_patch = _capture_subprocess()
with (
patch.object(gateway, "DEFAULT_MODEL", "env-default"),
patch.object(gateway, "FALLBACK_MODEL", ""),
process_patch,
):
session, ephemeral = asyncio.run(
gateway._resolve_session(_request(session_id="missing"), "system prompt")
)
try:
self.assertIs(ephemeral, True)
self.assertNotIn(session.id, gateway.SESSIONS)
self.assertEqual(len(captured), 1)
self.assertIn("--system-prompt", captured[0])
finally:
asyncio.run(session.close())
def test_v1_generate_reuses_session_id_without_ephemeral_close(self):
existing = gateway.EngineSession(model=None)
existing.proc = _FakeProcess()
gateway.SESSIONS["sid"] = existing
calls = []
closes = []
async def fake_turn(content, timeout=120.0):
calls.append((content, timeout))
return {"text": "reused response", "cost_usd": 0.01, "is_error": False}
async def fake_close():
closes.append(True)
existing.turn = fake_turn
existing.close = fake_close
response = asyncio.run(gateway.v1_generate(_request(session_id="sid")))
self.assertEqual(response["text"], "reused response")
self.assertEqual(response["provider"], "claude_cli")
self.assertEqual(calls, [("hello", 120.0)])
self.assertEqual(closes, [])
def test_v1_generate_closes_fresh_ephemeral_session(self):
started = []
turned = []
closed = []
async def fake_start(self):
started.append(self)
self.proc = _FakeProcess()
async def fake_turn(self, content, timeout=120.0):
turned.append((self, content, timeout))
return {"text": "fresh response", "cost_usd": 0.02, "is_error": False}
async def fake_close(self):
closed.append(self)
with (
patch.object(gateway.EngineSession, "start", fake_start),
patch.object(gateway.EngineSession, "turn", fake_turn),
patch.object(gateway.EngineSession, "close", fake_close),
):
response = asyncio.run(gateway.v1_generate(_request(session_id="missing")))
self.assertEqual(response["text"], "fresh response")
self.assertEqual(len(started), 1)
self.assertEqual(turned, [(started[0], "hello", 120.0)])
self.assertEqual(closed, [started[0]])
self.assertNotIn(started[0].id, gateway.SESSIONS)
if __name__ == "__main__":
unittest.main()

View file

@ -9,11 +9,14 @@
# 설치: pip install -r requirements-rag.txt (base requirements.txt 위에 추가)
# 임베딩 + 리랭커 (BGE-M3 dense/sparse/colbert + bge-reranker-v2-m3 단일 패키지)
FlagEmbedding>=1.2.10
# ⚠️ 버전 핀(2026-06-26 실검증): FlagEmbedding 1.4.x(신규 리라이트)는 BGEM3FlagModel 로드 시
# `XLMRobertaModel.__init__() got an unexpected keyword argument 'dtype'` 로 깨진다.
# 1.2.11 + transformers 4.44.2 조합에서 BGE-M3 정상 로드/인코딩(dense dim=1024) 확인.
FlagEmbedding==1.2.11
# BGE-M3 백엔드(transformers/torch). FlagEmbedding 의존성과 정합.
# BGE-M3 백엔드(transformers/torch). FlagEmbedding 1.2.11 과 정합하는 transformers 핀.
torch>=2.1
transformers>=4.40
transformers==4.44.2
# (선택) sentence-transformers — 보조 임베딩/유틸. FlagEmbedding 만으로도 BGE-M3 동작.
sentence-transformers>=2.7

View file

@ -16,6 +16,7 @@ npm run dev # http://localhost:5173 (개발 서버, /api → :8000 프록
```bash
npm run build # tsc -b + vite build → dist/
npm run generate:live2d-assets
npm run preview # 빌드 결과 미리보기
npm run typecheck # tsc --noEmit (타입 체크만)
```
@ -30,8 +31,11 @@ npm run typecheck # tsc --noEmit (타입 체크만)
|---|---|---|
| `VITE_API_BASE` | `/api` | API 베이스. 개발은 vite proxy, 프로덕션은 nginx가 백엔드로 라우팅 |
세션 아바타는 SVG/persona 렌더러만 사용한다. Mao/Haru 같은 샘플 Live2D 자산과
Pixi/Cubism 런타임은 공개 배포물에서 제거했다.
세션 아바타는 first-party SVG Live2D parameter rig를 사용한다. P4~P7 모델은
`components/avatar/live2dModel.ts`의 모델 계약으로 관리하며, 각 모델은 28개 표정 모션을
갖는다. `npm run generate:live2d-assets`는 이 계약에서
`public/live2d/personas/{p4..p7}``model3.json`/`exp3.json` 산출물을 재생성한다.
Mao/Haru 같은 샘플 Live2D 자산과 Pixi/Cubism 런타임은 공개 배포물에서 제거했다.
## 라우트
@ -68,7 +72,9 @@ src/
ProgressBar Field/Input Dot Icon(inline svg) SectionHead.
배럴: index.ts → import { Button, ... } from "../components/ui"
shell/ AppShell Sidebar Topbar (§6.3 공통 셸).
avatar/ClientAvatar.tsx 가상 내담자 SVG 아바타.
avatar/ClientAvatar.tsx 가상 내담자 SVG Live2D parameter rig.
avatar/live2dModel.ts P4~P7 모델/표정 모션 계약.
public/live2d/personas/ 생성된 first-party Live2D-compatible model3/exp3 자산.
pages/ Login + learner/teacher/admin/settings/session 화면.
```

View file

@ -129,6 +129,93 @@ async function openAdminAndReadUsers(page: Page) {
return (await usersResponse.json()) as AdminUsersResponse;
}
async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
const form = page.locator(".ad-user-create");
await expect(form).toBeVisible();
const clippedControls = await form.evaluate((element) => {
const formRect = element.getBoundingClientRect();
const controls = Array.from(element.querySelectorAll<HTMLElement>("input, select, button"));
return controls
.map((control) => {
const rect = control.getBoundingClientRect();
const style = window.getComputedStyle(control);
const tag = control.tagName.toLowerCase();
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const outsideForm =
rect.left < formRect.left - 1 ||
rect.right > formRect.right + 1 ||
rect.top < formRect.top - 1 ||
rect.bottom > formRect.bottom + 1;
const contentClipped =
tag === "button" &&
(control.scrollWidth > control.clientWidth + 1 ||
control.scrollHeight > control.clientHeight + 1);
return {
tag,
label: control.getAttribute("aria-label") ?? control.textContent?.replace(/\s+/g, " ").trim(),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
width: Math.ceil(rect.width),
outsideForm,
contentClipped,
visible,
};
})
.filter((control) => control.visible && (control.outsideForm || control.contentClipped));
});
expect(
clippedControls,
`Create-user controls clipped at ${viewportWidth}px: ${JSON.stringify(clippedControls)}`,
).toEqual([]);
}
async function expectVisibleButtonsFit(page: Page, selector: string, context: string) {
const clippedButtons = await page.locator(selector).evaluateAll((buttons) =>
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const owner = button.closest<HTMLElement>(".ad-user,.ad-user-create") ?? button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const contentClipped =
button.scrollWidth > button.clientWidth + 1 ||
button.scrollHeight > button.clientHeight + 1;
const outsideOwner = ownerRect
? rect.left < ownerRect.left - 1 ||
rect.right > ownerRect.right + 1 ||
rect.top < ownerRect.top - 1 ||
rect.bottom > ownerRect.bottom + 1
: false;
return {
text: button.textContent?.replace(/\s+/g, " ").trim(),
width: Math.ceil(rect.width),
contentClipped,
outsideOwner,
visible,
};
})
.filter((button) => button.visible && (button.contentClipped || button.outsideOwner)),
);
expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]);
}
test.describe("admin route", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
@ -229,6 +316,7 @@ test.describe("admin route", () => {
const card = page.locator(".ad-user").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
const nextName = `교수자 ${testInfo.project.name}`;
const nameInput = card.getByLabel(`${email} 표시 이름`);
@ -293,12 +381,39 @@ test.describe("admin route", () => {
await expect(page.locator(".ad-root")).toHaveCount(0);
});
test("does not horizontally overflow at a mobile viewport", async ({ page }) => {
test("keeps admin controls usable at a mobile viewport", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await signInAsAdmin(page);
await openAdminAndReadHealth(page);
const users = await openAdminAndReadUsers(page);
const layout = await page.evaluate(() => {
const workspace = document.querySelector<HTMLElement>(".ad-user-workspace");
const form = document.querySelector<HTMLElement>(".ad-user-create");
if (!workspace || !form) throw new Error("admin user workspace was not rendered");
return {
workspaceColumns: window.getComputedStyle(workspace).gridTemplateColumns.split(" ").length,
formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length,
};
});
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, 390);
expect(layout.workspaceColumns).toBe(1);
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});
test("keeps the create-user form contained at tablet widths", async ({ page }) => {
await signInAsAdmin(page);
for (const width of [861, 900, 1024]) {
await page.setViewportSize({ width, height: 900 });
await openAdminAndReadUsers(page);
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, width);
}
});
});

View file

@ -0,0 +1,117 @@
import { expect, test } from "@playwright/test";
const PERSONAS = [
{ code: "P4", model: "vignette-p4-live2d", defaultExpression: "anxious" },
{ code: "P5", model: "vignette-p5-live2d", defaultExpression: "guarded" },
{ code: "P6", model: "vignette-p6-live2d", defaultExpression: "conflicted" },
{ code: "P7", model: "vignette-p7-live2d", defaultExpression: "tired" },
] as const;
const REQUIRED_EXPRESSIONS = ["joy", "sad", "angry", "rage"] as const;
test.describe("avatar expression lab", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "avatar-lab-learner",
email: "learner@hs.ac.kr",
display_name: "Avatar Lab Learner",
role: "learner",
cohort_ids: [],
}),
}),
);
});
test("renders every P4-P7 expression motion as a visible first-party avatar preview", async ({
page,
}) => {
await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/learn/avatar-expressions");
const lab = page.locator('[data-avatar-expression-lab="true"]');
await expect(lab).toBeVisible();
await expect(lab).toHaveAttribute("data-persona-count", "4");
await expect(lab).toHaveAttribute("data-expression-count", "28");
await expect(lab).toHaveAttribute("data-rendered-avatar-count", "112");
for (const persona of PERSONAS) {
const panel = page.locator(
`[data-persona-expression-panel="true"][data-persona-code="${persona.code}"]`,
);
const code = persona.code.toLowerCase();
await expect(panel).toHaveAttribute("data-expression-count", "28");
await expect(panel).toHaveAttribute("data-live2d-model", persona.model);
await expect(panel).toHaveAttribute(
"data-live2d-model-url",
`/live2d/personas/${code}/${code}.model3.json`,
);
await expect(panel.locator('[data-expression-card="true"]')).toHaveCount(28);
const sectionMetrics = await panel.evaluate((el) => {
const avatars = Array.from(el.querySelectorAll<HTMLElement>(".axl__card .vg-avatar"));
const invalid = avatars
.map((avatar) => {
const neck = avatar.querySelector<SVGGraphicsElement>('[data-avatar-neck="true"]');
const svg = avatar.querySelector("svg");
return {
affect: avatar.getAttribute("data-affect"),
primitives: avatar.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: neck?.getBoundingClientRect().toJSON(),
svgBox: svg?.getBoundingClientRect().toJSON(),
};
})
.filter((item) => {
return (
item.primitives < 12 ||
!item.neckBox ||
item.neckBox.width <= 8 ||
item.neckBox.height <= 14 ||
!item.svgBox ||
item.svgBox.width <= 0 ||
item.svgBox.height <= 0
);
});
return {
avatarCount: avatars.length,
animatedCount: avatars.filter((avatar) => avatar.getAttribute("data-avatar-animated") === "true")
.length,
invalid,
};
});
expect(sectionMetrics.avatarCount, `${persona.code} rendered expression avatars`).toBe(28);
expect(sectionMetrics.animatedCount, `${persona.code} QA avatars should be static`).toBe(0);
expect(sectionMetrics.invalid, `${persona.code} visible avatar geometry`).toEqual([]);
const defaultAvatar = panel.locator(".axl__persona-head .vg-avatar").first();
await expect(defaultAvatar).toHaveAttribute("data-affect", persona.defaultExpression);
await expect(defaultAvatar).toHaveAttribute("data-live2d-model", persona.model);
await expect(defaultAvatar).toHaveAttribute("data-live2d-expression-count", "28");
for (const expression of REQUIRED_EXPRESSIONS) {
const card = panel.locator(
`[data-expression-card="true"][data-expression="${expression}"]`,
);
await expect(card).toHaveAttribute("data-motion-file", `expressions/${expression}.exp3.json`);
await expect(card).toHaveAttribute("data-fade-in-ms", "260");
const avatar = card.locator(".vg-avatar").first();
await expect(avatar).toHaveAttribute("data-affect", expression);
await expect(avatar).toHaveAttribute("data-live2d-motion", expression);
await expect(avatar).toHaveAttribute(
"data-live2d-motion-file",
`expressions/${expression}.exp3.json`,
);
await expect(avatar).toHaveAttribute("data-live2d-expression-count", "28");
await expect(avatar).toHaveAttribute("data-avatar-animated", "false");
}
}
});
});

View file

@ -0,0 +1,220 @@
import { expect, test } from "@playwright/test";
const PERSONAS = [
{
code: "P4",
display_name: "하늘(가명) · 고2 · 학업/시험 불안",
difficulty: "easy",
theory_target: ["cbt", "humanistic"],
demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" },
presenting_summary: "시험 불안과 완벽주의 부담",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "anxious",
expectedModel: "vignette-p4-live2d",
},
{
code: "P5",
display_name: "도윤(가명) · 중3 · 또래관계 갈등/소외감",
difficulty: "moderate",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "14-16", sex: "male", grade: "중3", status: "재학" },
presenting_summary: "또래관계 갈등과 소외감",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "guarded",
expectedModel: "vignette-p5-live2d",
},
{
code: "P6",
display_name: "하린(가명) · 고3 · 진로갈등",
difficulty: "moderate",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "16-18", sex: "female", grade: "고3", status: "진로갈등" },
presenting_summary: "부모 기대와 본인 욕구 사이의 진로갈등",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "conflicted",
expectedModel: "vignette-p6-live2d",
},
{
code: "P7",
display_name: "도현(가명) · 고3 · 입시 번아웃/무기력",
difficulty: "hard",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "16-18", sex: "male", grade: "고3", status: "정시 준비" },
presenting_summary: "입시 번아웃과 무기력",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "tired",
expectedModel: "vignette-p7-live2d",
},
] as const;
test.describe("persona avatar expression rig", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "avatar-expression-learner",
email: "learner@hs.ac.kr",
display_name: "Avatar Expression Learner",
role: "learner",
cohort_ids: [],
}),
}),
);
await page.route("**/api/personas", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(
PERSONAS.map(
({
expectedExpression: _expectedExpression,
expectedModel: _expectedModel,
...persona
}) => persona,
),
),
}),
);
});
test("renders every persona with a distinct baseline expression and at least 20 expressions", async ({
page,
}) => {
for (const persona of PERSONAS) {
await page.goto(`/learn/session/${persona.code}`);
const avatar = page.locator(`.vg-avatar[data-persona-code="${persona.code}"]`).first();
await expect(avatar).toBeVisible();
await expect(avatar).toHaveAttribute("data-affect", persona.expectedExpression);
const metrics = await avatar.evaluate((el) => ({
expressionCount: Number(el.getAttribute("data-expression-count")),
live2dSchema: el.getAttribute("data-live2d-schema"),
live2dModel: el.getAttribute("data-live2d-model"),
live2dModelUrl: el.getAttribute("data-live2d-model-url"),
live2dMotion: el.getAttribute("data-live2d-motion"),
live2dMotionFile: el.getAttribute("data-live2d-motion-file"),
live2dExpressionCount: Number(el.getAttribute("data-live2d-expression-count")),
primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: el.querySelector<SVGGraphicsElement>('[data-avatar-neck="true"]')?.getBoundingClientRect().toJSON(),
svgBox: el.querySelector("svg")?.getBoundingClientRect().toJSON(),
}));
expect(metrics.expressionCount, `${persona.code} expression count`).toBeGreaterThanOrEqual(20);
expect(metrics.live2dSchema, `${persona.code} Live2D schema`).toBe("vignette.live2d.v1");
expect(metrics.live2dModel, `${persona.code} Live2D model`).toBe(persona.expectedModel);
expect(metrics.live2dModelUrl, `${persona.code} Live2D model URL`).toBe(
`/live2d/personas/${persona.code.toLowerCase()}/${persona.code.toLowerCase()}.model3.json`,
);
expect(metrics.live2dMotion, `${persona.code} Live2D motion`).toBe(persona.expectedExpression);
expect(metrics.live2dMotionFile, `${persona.code} Live2D motion file`).toBe(
`expressions/${persona.expectedExpression}.exp3.json`,
);
expect(metrics.live2dExpressionCount, `${persona.code} Live2D expressions`).toBeGreaterThanOrEqual(20);
expect(metrics.primitiveCount, `${persona.code} avatar SVG primitives`).toBeGreaterThanOrEqual(12);
expect(metrics.neckBox?.width, `${persona.code} visible neck width`).toBeGreaterThan(12);
expect(metrics.neckBox?.height, `${persona.code} visible neck height`).toBeGreaterThan(24);
expect(metrics.svgBox?.width, `${persona.code} avatar SVG width`).toBeGreaterThan(0);
expect(metrics.svgBox?.height, `${persona.code} avatar SVG height`).toBeGreaterThan(0);
}
});
test("keeps the active session avatar expression wired after session start", async ({ page }) => {
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "11111111-1111-4111-8111-111111111111",
case_id: "case-avatar-expression",
session_no: 1,
stage: "라포",
effective_openness: 0.2,
recall_summary: null,
degraded: false,
}),
});
});
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-model", "vignette-p4-live2d");
await expect(activeAvatar).toHaveAttribute("data-live2d-model-url", "/live2d/personas/p4/p4.model3.json");
await expect(activeAvatar).toHaveAttribute("data-live2d-fade-in-ms", "260");
await expect(activeAvatar).toHaveAttribute("data-live2d-motion-file", "expressions/anxious.exp3.json");
await expect(activeAvatar).toHaveAttribute("data-affect", "anxious");
await expect(page.locator(".sx-stage__now")).toContainText("불안");
});
test("animates expression transitions after session openness changes", async ({ page }) => {
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "22222222-2222-4222-8222-222222222222",
case_id: "case-avatar-transition",
session_no: 1,
stage: "라포",
effective_openness: 0.2,
recall_summary: null,
degraded: false,
}),
});
});
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
"data: 조금은 괜찮아진 것 같아요.",
"",
"event: done",
'data: {"session_id":"22222222-2222-4222-8222-222222222222","stage":"정리","effective_openness":0.86,"safety_flagged":false}',
"",
].join("\n"),
});
});
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
await page.getByLabel("학습자 발화 입력").fill("조금 안정된 것 같아요.");
await page.getByRole("button", { name: "보내기" }).click();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "warm");
await expect(activeAvatar).toHaveAttribute("data-live2d-motion-file", "expressions/warm.exp3.json");
await expect(activeAvatar).toHaveAttribute("data-live2d-fade-in-ms", "260");
await expect(activeAvatar).toHaveAttribute("data-live2d-transition-progress", "1.00");
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
});
});

View file

@ -0,0 +1,290 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import {
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
signInAsTeacher,
} from "./support";
/**
* Strict visual layout gate.
*
* The layout-redesign handoff (docs/ops/layout-redesign-handoff-2026-06-26.md)
* required a human-style visual acceptance pass across the redesigned screens at
* the suggested breakpoints. This spec hardens that pass into an automated gate:
* every redesigned screen is rendered at every required width, asserted free of
* horizontal overflow and clipped primary controls, and captured as a full-page
* screenshot artifact for review. Any single failure fails the whole gate.
*/
const GATE_WIDTHS = [
{ width: 390, height: 844, label: "390-mobile" },
{ width: 720, height: 900, label: "720-phablet" },
{ width: 861, height: 900, label: "861-tablet-min" },
{ width: 900, height: 900, label: "900-tablet" },
{ width: 1024, height: 768, label: "1024-tablet-land" },
{ width: 1280, height: 800, label: "1280-laptop" },
{ width: 1440, height: 900, label: "1440-desktop" },
] as const;
const SHOT_DIR = path.join(process.cwd(), "node_modules", ".tmp", "layout-gate");
async function ensureShotDir() {
await fs.mkdir(SHOT_DIR, { recursive: true });
}
interface ClipReport {
viewport: { width: number; height: number };
horizontalOverflow: number;
offenders: Array<{
tag: string;
role: string;
className: string;
text: string;
reason: string;
left: number;
right: number;
}>;
}
/**
* Scans every visible interactive control and prominent text container for
* either (a) extending beyond the viewport horizontally, or (b) clipping its own
* content (scrollWidth/scrollHeight exceeding the client box) the two failure
* modes the redesign was meant to eliminate.
*/
async function auditClipping(page: Page): Promise<ClipReport> {
return page.evaluate(() => {
const doc = document.documentElement;
const viewport = { width: doc.clientWidth, height: window.innerHeight };
const selector = [
"button",
"a[href]",
"input",
"select",
"textarea",
"[role='tab']",
"[role='button']",
"[role='option']",
"h1",
"h2",
"h3",
".vg-btn",
].join(",");
// Walks ancestors to find the nearest box that clips overflow. Returns the
// clipping rect when that ancestor is NOT scrollable (i.e. content cut off,
// not reachable by scrolling). A scrollable carousel (overflow auto/scroll)
// legitimately holds off-screen children, so it is treated as non-clipping.
// X-axis only: offender detection compares horizontal edges, so only the
// horizontal overflow behaviour of ancestors matters. A horizontal carousel
// (overflow-x auto/scroll) holds reachable off-screen children and is fine;
// overflow-x hidden genuinely cuts content off.
function nearestHardClip(el: HTMLElement): DOMRect | null {
let node: HTMLElement | null = el.parentElement;
while (node && node !== document.body && node !== document.documentElement) {
const ox = window.getComputedStyle(node).overflowX;
if (ox === "auto" || ox === "scroll") return null; // reachable by scroll
if (ox === "hidden" || ox === "clip") return node.getBoundingClientRect();
node = node.parentElement;
}
return null;
}
const offenders: ClipReport["offenders"] = [];
const nodes = Array.from(document.querySelectorAll<HTMLElement>(selector));
for (const el of nodes) {
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
if (!visible) continue;
// Clipped by a hard (non-scrollable) overflow ancestor: content is cut off.
const clipRect = nearestHardClip(el);
const clippedByAncestor =
!!clipRect && (rect.right > clipRect.right + 1 || rect.left < clipRect.left - 1);
// Content clipping: the element cannot show its own text/children — but
// intentional truncation affordances (ellipsis, -webkit-line-clamp) are
// design choices the redesign uses for dense data, not defects.
const clipsX = style.overflowX === "hidden" || style.overflowX === "clip";
const clipsY = style.overflowY === "hidden" || style.overflowY === "clip";
const lineClamp =
style.getPropertyValue("-webkit-line-clamp") || (style as unknown as { webkitLineClamp?: string }).webkitLineClamp || "none";
const hasLineClamp = lineClamp !== "none" && lineClamp !== "" && lineClamp !== "0";
const hasEllipsis = style.textOverflow === "ellipsis";
// 폼 컨트롤(input/textarea/select)은 자기 값을 *설계상* 스크롤한다(커서/키보드로 전부
// 도달 가능). 박스보다 긴 값은 잘린 결함이 아니라 정상 스크롤 UX → ellipsis/line-clamp
// 와 같은 의도된 어포던스로 보고 text-clip 판정에서 제외(clipped-by-ancestor·가로 overflow는 유지).
const tagName = el.tagName.toLowerCase();
const isFormControl =
tagName === "input" || tagName === "textarea" || tagName === "select";
const textClippedX =
clipsX && !hasEllipsis && !isFormControl && Math.ceil(el.scrollWidth - el.clientWidth) > 1;
const textClippedY =
clipsY && !hasLineClamp && !isFormControl && Math.ceil(el.scrollHeight - el.clientHeight) > 1;
if (clippedByAncestor || textClippedX || textClippedY) {
const reasons: string[] = [];
if (clippedByAncestor) reasons.push("clipped-by-ancestor");
if (textClippedX) reasons.push("text-clipped-x");
if (textClippedY) reasons.push("text-clipped-y");
offenders.push({
tag: el.tagName.toLowerCase(),
role: el.getAttribute("role") ?? "",
className: String(el.className || "").slice(0, 80),
text: (el.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 60),
reason: reasons.join(","),
left: Math.floor(rect.left),
right: Math.ceil(rect.right),
});
}
if (offenders.length >= 16) break;
}
return {
viewport,
horizontalOverflow: Math.ceil(doc.scrollWidth - doc.clientWidth),
offenders,
};
});
}
async function gateScreen(
page: Page,
screen: string,
prepareReady: () => Promise<void>,
) {
for (const vp of GATE_WIDTHS) {
await page.setViewportSize({ width: vp.width, height: vp.height });
await page.evaluate(() => new Promise((r) => requestAnimationFrame(() => r(null))));
await prepareReady();
await expectNoHorizontalOverflow(page);
const report = await auditClipping(page);
expect(
report.horizontalOverflow,
`[${screen} @ ${vp.label}] horizontal overflow ${report.horizontalOverflow}px`,
).toBeLessThanOrEqual(1);
expect(
report.offenders,
`[${screen} @ ${vp.label}] clipped/overflowing controls: ${JSON.stringify(
report.offenders,
null,
2,
)}`,
).toEqual([]);
await page.screenshot({
path: path.join(SHOT_DIR, `${screen}__${vp.label}.png`),
fullPage: true,
});
}
}
test.describe("layout visual gate @single-run", () => {
test.describe.configure({ mode: "serial" });
test.beforeAll(async () => {
await ensureShotDir();
});
test("learner home stays contained and legible across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: {
email: `gate.learner.${Date.now()}@hs.ac.kr`,
role: "learner",
display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
},
});
// Seed dense history: one active + two ended sessions.
const persona = await fetchAvailablePersona(page);
const made: string[] = [];
for (let i = 0; i < 3; i += 1) {
const res = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
const body = (await res.json()) as { session_id: string };
made.push(body.session_id);
}
await page.request.post(`/api/sessions/${made[1]}/end`);
await page.request.post(`/api/sessions/${made[2]}/end`);
await page.goto("/learn");
await gateScreen(page, "learner-home", async () => {
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
});
});
test("session prestart stays contained across all widths", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await gateScreen(page, "session-prestart", async () => {
await expect(page.getByRole("button", { name: "회기 시작" })).toBeVisible({
timeout: 15_000,
});
});
});
test("active session keeps controls contained across all widths", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page--active")).toBeVisible({ timeout: 15_000 });
await gateScreen(page, "session-active", async () => {
await expect(page.locator(".sx-page--active")).toBeVisible();
});
});
test("session review stays contained across all widths", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
const session = (await start.json()) as { session_id: string };
await page.request.post(`/api/sessions/${session.session_id}/end`);
await page.goto(`/learn/session/${session.session_id}/review`);
await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
});
});
test("professor console stays contained across all widths", async ({ page }) => {
await signInAsTeacher(page);
await page.goto("/teach");
await gateScreen(page, "professor", async () => {
await expect(page.locator(".pf-panel, .pf-shell, main").first()).toBeVisible({
timeout: 15_000,
});
});
});
test("admin console stays contained across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },
});
await page.goto("/admin");
await gateScreen(page, "admin", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
});
});
test("settings stays contained across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },
});
await page.goto("/settings");
await gateScreen(page, "settings", async () => {
await expect(page.locator("main").first()).toBeVisible({ timeout: 15_000 });
});
});
});

View file

@ -69,6 +69,25 @@ async function expectVisibleResumeLoadedSignal(page: import("@playwright/test").
).toBeTruthy();
}
async function expectReachableLearnerHomeLayout(page: import("@playwright/test").Page) {
await expect(page.locator(".lh-preview__main")).toBeVisible();
await expect(page.locator(".lh-activity")).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
const rootOverflow = await page.locator(".lh-root").evaluate((root) => {
const style = window.getComputedStyle(root);
return { overflow: style.overflow, overflowY: style.overflowY };
});
expect(rootOverflow.overflow).not.toBe("hidden");
expect(rootOverflow.overflowY).not.toBe("hidden");
await expectNoHorizontalOverflow(page);
}
function learnerActivityHeading(page: import("@playwright/test").Page) {
return page.locator(".lh-activity__head").getByText("기존 회기", { exact: true });
}
test.describe("learner app shell and session launcher", () => {
test.beforeEach(async ({ page }) => {
await useRealApi(page);
@ -86,10 +105,11 @@ test.describe("learner app shell and session launcher", () => {
await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
await page.goto("/learn");
const personas = await fetchAvailablePersonas(page);
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
const launcher = page.getByRole("listbox", { name: "연습 페르소나" });
await expect(launcher).toBeVisible();
await expect(launcher.getByRole("option")).toHaveCount(personas.length);
await expect(launcher.getByRole("option")).toHaveCount(personas.length, { timeout: 15_000 });
for (const persona of personas) {
await expect(launcher.getByRole("option", { name: new RegExp(persona.code) })).toBeVisible();
await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).toBeVisible();
@ -99,11 +119,15 @@ test.describe("learner app shell and session launcher", () => {
await expect(page.getByText(/최근 연습/)).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.getByText("음성")).toBeVisible();
await page.getByText("음성").scrollIntoViewIfNeeded();
await expect(page.getByText("음성")).toBeInViewport();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await page.getByRole("button", { name: "새 회기 시작" }).scrollIntoViewIfNeeded();
await expectReachableLearnerHomeLayout(page);
const activityHeading = learnerActivityHeading(page);
await activityHeading.scrollIntoViewIfNeeded();
await expect(activityHeading).toBeInViewport();
await expectNoLearnerInternalCopy(page);
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
});
test("routes the launcher CTA to the selected database persona", async ({ page }) => {
@ -142,12 +166,14 @@ test.describe("learner app shell and session launcher", () => {
expect(endResponse.ok(), await endResponse.text()).toBeTruthy();
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(learnerActivityHeading(page)).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록" })).toBeVisible();
await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2);
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expectReachableLearnerHomeLayout(page);
await page.locator(".lh-activity").scrollIntoViewIfNeeded();
await expect(page.getByRole("button", { name: "이어하기" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록" })).toBeInViewport();
await expect(page.getByRole("button", { name: "다시 연습" }).first()).toBeInViewport();
@ -167,11 +193,13 @@ test.describe("learner app shell and session launcher", () => {
await signInAsLearner(page);
await page.goto("/learn/session/P9");
await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible();
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "연습 대상 정보를 확인하고 있습니다." }),
).toBeVisible();
).toBeVisible({ timeout: 15_000 });
await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible({
timeout: 15_000,
});
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
await expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);

View file

@ -0,0 +1,101 @@
import path from "node:path";
import { pathToFileURL } from "node:url";
import { expect, test } from "@playwright/test";
const PERSONA_CODES = ["p4", "p5", "p6", "p7"] as const;
test.describe("first-party Live2D assets", () => {
test("serves model3 manifests and exp3 expressions for every persona", async ({ request }) => {
const indexResponse = await request.get("/live2d/personas/index.json");
expect(indexResponse.ok(), await indexResponse.text()).toBeTruthy();
const index = (await indexResponse.json()) as {
personas: { code: string; model3: string; expressionCount: number }[];
};
expect(index.personas).toHaveLength(4);
for (const code of PERSONA_CODES) {
const modelResponse = await request.get(`/live2d/personas/${code}/${code}.model3.json`);
expect(modelResponse.ok(), await modelResponse.text()).toBeTruthy();
const model = (await modelResponse.json()) as {
Version: number;
Vignette: { ModelId: string; PersonaCode: string };
FileReferences: { Expressions: { Name: string; File: string }[] };
};
expect(model.Version).toBe(3);
expect(model.Vignette.ModelId).toBe(`vignette-${code}-live2d`);
expect(model.Vignette.PersonaCode.toLowerCase()).toBe(code);
expect(model.FileReferences.Expressions.length).toBeGreaterThanOrEqual(20);
expect(model.FileReferences.Expressions.map((expression) => expression.Name)).toEqual(
expect.arrayContaining(["joy", "sad", "angry", "rage"]),
);
const rage = model.FileReferences.Expressions.find((expression) => expression.Name === "rage");
expect(rage, `${code} rage expression`).toBeTruthy();
const expressionResponse = await request.get(`/live2d/personas/${code}/${rage!.File}`);
expect(expressionResponse.ok(), await expressionResponse.text()).toBeTruthy();
const expression = (await expressionResponse.json()) as {
Type: string;
Version: number;
FadeInTime: number;
FadeOutTime: number;
Parameters: { Id: string; Value: number; Blend: string }[];
};
expect(expression.Type).toBe("Live2D Expression");
expect(expression.Version).toBe(3);
expect(expression.FadeInTime).toBe(0.26);
expect(expression.FadeOutTime).toBe(0.32);
expect(expression.Parameters.length).toBeGreaterThanOrEqual(12);
expect(expression.Parameters.map((parameter) => parameter.Id)).toEqual(
expect.arrayContaining(["ParamMouthOpenY", "ParamMouthForm", "ParamEyeLOpen"]),
);
}
});
test("keeps legacy demo paths blocked while allowing first-party persona assets at the Pages function", async () => {
const functionPath = path.resolve("functions", "live2d", "[[path]].js");
const { onRequest } = (await import(pathToFileURL(functionPath).href)) as {
onRequest: (context: {
request: Request;
params: { path: string[] };
env: { ASSETS: { fetch: (request: Request) => Promise<Response> } };
}) => Promise<Response>;
};
const allowed = await onRequest({
request: new Request("https://vignette.example/live2d/personas/p4/p4.model3.json"),
params: { path: ["personas", "p4", "p4.model3.json"] },
env: {
ASSETS: {
fetch: async () => new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
},
},
});
expect(allowed.status).toBe(200);
expect(allowed.headers.get("x-robots-tag")).toBe("noindex");
const legacy = await onRequest({
request: new Request("https://vignette.example/live2d/mao/Mao.model3.json"),
params: { path: ["mao", "Mao.model3.json"] },
env: {
ASSETS: {
fetch: async () => new Response("should not be called", { status: 200 }),
},
},
});
expect(legacy.status).toBe(404);
const traversal = await onRequest({
request: new Request("https://vignette.example/live2d/personas/p4/../mao/Mao.model3.json"),
params: { path: ["personas", "p4", "..", "mao", "Mao.model3.json"] },
env: {
ASSETS: {
fetch: async () => new Response("should not be called", { status: 200 }),
},
},
});
expect(traversal.status).toBe(404);
});
});

View file

@ -55,16 +55,15 @@ async function expectMobileContextIfNarrow(page: Page) {
const isPhoneLayout = await page.evaluate(() =>
window.matchMedia("(max-width: 880px)").matches,
);
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
if (isPhoneLayout) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
} else {
const feedbackSurface = page.locator(".sx-page--active .sx-col-right");
await expect(feedbackSurface).toBeVisible();
await expect(feedbackSurface).toBeInViewport();
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
}
const mobileContext = page.getByLabel("현재 회기 요약");
await expect(mobileContext).toBeVisible();
await expect(page.locator(".sx-page--active .sx-mobile-context__brief")).toBeVisible();
await expect(mobileContext).toContainText("조용히 표시");
await expect(mobileContext).toContainText("내담자");
await expect(mobileContext).toContainText("마이크");
@ -122,6 +121,225 @@ async function expectSessionControlsInsideViewport(page: Page) {
).toEqual([]);
}
async function expectNoVisibleSessionPanelOverlap(page: Page) {
const result = await page.evaluate(() => {
const selectors = [
".sx-page--active .sx-mobile-context",
".sx-page--active .sx-col-left",
".sx-page--active .sx-stage",
".sx-page--active .sx-transcript",
".sx-page--active .sx-col-right",
".sx-page--active .sx-controlbar",
];
const panels = selectors
.map((selector) => {
const el = document.querySelector<HTMLElement>(selector);
if (!el) return null;
const rect = el.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
if (!visible) return null;
return {
selector,
rect: {
top: rect.top,
right: rect.right,
bottom: rect.bottom,
left: rect.left,
width: rect.width,
height: rect.height,
},
};
})
.filter((item): item is NonNullable<typeof item> => Boolean(item));
const overlaps: Array<{ a: string; b: string; area: number }> = [];
for (let i = 0; i < panels.length; i += 1) {
for (let j = i + 1; j < panels.length; j += 1) {
const a = panels[i];
const b = panels[j];
const width = Math.min(a.rect.right, b.rect.right) - Math.max(a.rect.left, b.rect.left);
const height = Math.min(a.rect.bottom, b.rect.bottom) - Math.max(a.rect.top, b.rect.top);
const area = Math.max(0, width) * Math.max(0, height);
if (area > 1) {
overlaps.push({ a: a.selector, b: b.selector, area: Math.round(area) });
}
}
}
return {
viewport: { width: window.innerWidth, height: window.innerHeight },
panels: panels.map((panel) => ({
selector: panel.selector,
rect: {
top: Math.round(panel.rect.top),
right: Math.round(panel.rect.right),
bottom: Math.round(panel.rect.bottom),
left: Math.round(panel.rect.left),
width: Math.round(panel.rect.width),
height: Math.round(panel.rect.height),
},
})),
overlaps,
};
});
expect(
result.overlaps,
`Visible session panels overlap at ${result.viewport.width}x${result.viewport.height}: ${JSON.stringify(result)}`,
).toEqual([]);
}
async function expectMainControlsUnclipped(page: Page) {
const result = await page.evaluate(() => {
const controls = [
{ selector: ".sx-compose textarea", parent: ".sx-compose" },
{ selector: ".sx-compose .vg-btn", parent: ".sx-compose" },
{ selector: ".sx-controlbar .sx-mic", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-segmented", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-pause", parent: ".sx-controlbar" },
{ selector: ".sx-controlbar .sx-slide-end", parent: ".sx-controlbar" },
];
return controls.map(({ selector, parent }) => {
const el = document.querySelector<HTMLElement>(selector);
const parentEl = document.querySelector<HTMLElement>(parent);
if (!el || !parentEl) return { selector, ok: false, reason: "missing" };
const rect = el.getBoundingClientRect();
const parentRect = parentEl.getBoundingClientRect();
const style = window.getComputedStyle(el);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const textCanClip = Number.parseFloat(style.fontSize) > 0;
const textClipped =
textCanClip &&
(Math.ceil(el.scrollWidth - el.clientWidth) > 1 ||
Math.ceil(el.scrollHeight - el.clientHeight) > 1);
const insideParent =
rect.top >= parentRect.top - 1 &&
rect.left >= parentRect.left - 1 &&
rect.right <= parentRect.right + 1 &&
rect.bottom <= parentRect.bottom + 1;
return {
selector,
ok: visible && insideParent && !textClipped,
reason: !visible ? "not-visible" : !insideParent ? "outside-parent" : textClipped ? "text-clipped" : "",
rect: {
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height),
},
parentRect: {
top: Math.round(parentRect.top),
right: Math.round(parentRect.right),
bottom: Math.round(parentRect.bottom),
left: Math.round(parentRect.left),
width: Math.round(parentRect.width),
height: Math.round(parentRect.height),
},
scrollWidth: el.scrollWidth,
clientWidth: el.clientWidth,
scrollHeight: el.scrollHeight,
clientHeight: el.clientHeight,
};
});
});
const failures = result.filter((check) => !check.ok);
expect(failures, `Main session controls are clipped: ${JSON.stringify(failures)}`).toEqual([]);
}
async function expectRightPanelDoesNotIntersectSessionCore(page: Page) {
const result = await page.evaluate(() => {
const right = document.querySelector<HTMLElement>(".sx-page--active .sx-col-right");
const coreSelectors = [
".sx-page--active .sx-col-center",
".sx-page--active .sx-stage",
".sx-page--active .sx-transcript",
".sx-page--active .sx-controlbar",
];
const toSnapshot = (rect: DOMRect) => ({
top: Math.round(rect.top),
right: Math.round(rect.right),
bottom: Math.round(rect.bottom),
left: Math.round(rect.left),
width: Math.round(rect.width),
height: Math.round(rect.height),
});
if (!right) {
return { ok: false, reason: "missing-right-panel" };
}
const rightRect = right.getBoundingClientRect();
const rightStyle = window.getComputedStyle(right);
const rightVisible =
rightStyle.display !== "none" &&
rightStyle.visibility !== "hidden" &&
Number(rightStyle.opacity) !== 0 &&
rightRect.width > 0 &&
rightRect.height > 0;
const missing: string[] = [];
const intersections: Array<{ selector: string; rect: ReturnType<typeof toSnapshot> }> = [];
for (const selector of coreSelectors) {
const el = document.querySelector<HTMLElement>(selector);
if (!el) {
missing.push(selector);
continue;
}
const rect = el.getBoundingClientRect();
const intersects =
rightVisible &&
rightRect.left < rect.right - 1 &&
rightRect.right > rect.left + 1 &&
rightRect.top < rect.bottom - 1 &&
rightRect.bottom > rect.top + 1;
if (intersects) {
intersections.push({ selector, rect: toSnapshot(rect) });
}
}
return {
ok: missing.length === 0 && intersections.length === 0,
viewport: { width: window.innerWidth, height: window.innerHeight },
narrow: window.matchMedia("(max-width: 1180px)").matches,
rightVisible,
rightRect: toSnapshot(rightRect),
missing,
intersections,
};
});
expect(
result.ok,
`Right feedback panel intersects session core: ${JSON.stringify(result)}`,
).toBeTruthy();
if ("narrow" in result && result.narrow) {
expect(
result.rightVisible,
`Right feedback panel should be hidden at <=1180px: ${JSON.stringify(result)}`,
).toBe(false);
}
}
async function expectActiveSessionUsableLayout(page: Page) {
const result = await page.evaluate(() => {
const grid = document.querySelector<HTMLElement>(".sx-page--active .sx-grid");
@ -144,12 +362,18 @@ async function expectActiveSessionUsableLayout(page: Page) {
const stageOverflow = stage.scrollHeight - stage.clientHeight;
const transcriptOverflow = transcript.scrollHeight - transcript.clientHeight;
const phone = window.matchMedia("(max-width: 880px)").matches;
const centerHeight = centerRect.height;
const stageHeight = stageRect.height;
const transcriptHeight = transcriptRect.height;
return {
ok: true,
phone,
gridWidth: Math.round(gridRect.width),
centerWidth: Math.round(centerRect.width),
centerHeight: Math.round(centerHeight),
stageHeight: Math.round(stageHeight),
transcriptHeight: Math.round(transcriptHeight),
scrollHeight: Math.round(scroll.getBoundingClientRect().height),
stageOverflow,
transcriptOverflow,
@ -170,6 +394,14 @@ async function expectActiveSessionUsableLayout(page: Page) {
).toBeLessThanOrEqual(2);
}
expect(result.scrollHeight, `Transcript viewport too small: ${JSON.stringify(result)}`).toBeGreaterThanOrEqual(110);
expect(
result.transcriptHeight,
`Transcript should be the dominant practice area: ${JSON.stringify(result)}`,
).toBeGreaterThanOrEqual(result.stageHeight);
expect(
result.transcriptHeight / result.centerHeight,
`Transcript is using too little of the center column: ${JSON.stringify(result)}`,
).toBeGreaterThanOrEqual(0.52);
expect(result.stageOverflow, `Stage content clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.transcriptOverflow, `Transcript chrome clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
expect(result.stageBottom, `Stage overlaps transcript: ${JSON.stringify(result)}`).toBeLessThanOrEqual(result.transcriptTop);
@ -202,6 +434,9 @@ test.describe("learner session full-screen layout", () => {
await expectSessionPageHeightToMatchViewport(page);
await expectMobileContextIfNarrow(page);
await expectSessionControlsInsideViewport(page);
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectRightPanelDoesNotIntersectSessionCore(page);
});
test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => {
@ -211,8 +446,12 @@ test.describe("learner session full-screen layout", () => {
const viewports = [
{ width: 1366, height: 768 },
{ width: 1366, height: 720 },
{ width: 1180, height: 768 },
{ width: 1100, height: 768 },
{ width: 1024, height: 768 },
{ width: 1024, height: 640 },
{ width: 900, height: 768 },
{ width: 881, height: 768 },
{ width: 820, height: 1180 },
{ width: 390, height: 844 },
{ width: 375, height: 667 },
@ -233,19 +472,23 @@ test.describe("learner session full-screen layout", () => {
await expectNoHorizontalOverflow(page);
await expectNoLocalStageDemoControl(page);
await expectSessionControlsInsideViewport(page);
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectSessionPageHeightToMatchViewport(page);
await expectActiveSessionUsableLayout(page);
await expectRightPanelDoesNotIntersectSessionCore(page);
if (viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-mobile-context")).toBeVisible();
}
if (viewport.width > 880 && viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
} else {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
await expect(page.locator(".sx-page--active .sx-col-right")).toBeInViewport();
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
}
if (viewport.width <= 880) {
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
if (viewport.width > 880 && viewport.width <= 1180) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
} else if (viewport.width <= 880) {
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
}
}
});

View file

@ -0,0 +1,186 @@
import { expect, test, type Page } from "@playwright/test";
const sessionId = "mvp-session-001";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
async function routeMvpApi(page: Page) {
await page.route("**/api/auth/me", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
user_id: "00000000-0000-0000-0000-00000000e2e1",
email: "mvp.learner@hs.ac.kr",
display_name: "MVP Learner",
role: "learner",
cohort_ids: [],
}),
});
});
await page.route("**/api/personas", async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
code: "P1",
display_name: "민서(청소년 우울)",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "10대" },
presenting_summary: "자퇴와 무기력감을 둘러싼 상담 연습",
voice_preset: "soft-young-fem",
source: "database",
degraded: false,
},
]),
});
});
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
return;
}
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
case_id: "mvp-case-001",
session_no: 1,
stage: "라포",
effective_openness: 0.21,
recall_summary: null,
degraded: false,
}),
});
});
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
await route.fulfill({
status: 200,
contentType: "text/event-stream",
body: [
"event: token",
`data: ${clientReply}`,
"",
"event: done",
`data: ${JSON.stringify({
session_id: sessionId,
stage: "탐색",
effective_openness: 0.42,
turn_seq: 1,
safety_flagged: false,
})}`,
"",
].join("\n"),
});
});
await page.route(`**/api/sessions/${sessionId}/end`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
session_no: 1,
digest_pending: true,
end_state: { stage: "탐색", turn_seq: 1, effective_openness: 0.42 },
}),
});
});
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
client: {
name: "민서",
initial: "민",
persona: "P1 · hard",
},
date: "2026-06-26",
durationLabel: "1분 02초",
durationSeconds: 62,
reachedPhase: "탐색",
sessionSignal: "종료됨",
supervisorState: "평가 완료",
supervisorName: "AI",
summary: "평가 AI가 저장된 축어록을 분석했습니다.",
phases: [{ key: "explore", label: "탐색", weight: 1 }],
phaseAxis: ["0:00", "1:02"],
valenceAxis: ["0:00", "1:02"],
clientValence: [],
counselorBaseline: [],
turns: [
{
id: "t1",
ts: "0:01",
speaker: "learner",
who: "학습자",
text: learnerText,
techniques: [],
note: null,
},
{
id: "t2",
ts: "0:04",
speaker: "client",
who: "민서",
text: clientReply,
techniques: [],
note: null,
},
],
rubric: [],
goodMoments: [{ title: "반영", body: "학습자가 정서를 먼저 반영했습니다." }],
growthPoints: [{ title: "탐색 확장", body: "다음 턴에서 구체 상황을 더 묻습니다." }],
nextLine: "그 말을 꺼내는 것도 쉽지 않았을 것 같아요.",
clientFeedback: clientReply,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
reviewReady: true,
}),
});
});
}
test.describe("P1 MVP core loop", () => {
test("runs login, P1 text stream, session end, and review feedback @single-run", async ({
page,
}) => {
const diagnostics: string[] = [];
page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
});
await routeMvpApi(page);
await page.goto("/learn/session/P1");
await expect(
page.getByRole("button", { name: "회기 시작" }),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await page.getByLabel("학습자 발화 입력").fill(learnerText);
await page.getByRole("button", { name: "보내기" }).click();
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await page.getByRole("button", { name: /밀어서 회기 종료/ }).press("Enter");
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("내담자가 남긴 것")).toBeVisible();
await expect(page.locator(".sr-feedback").getByText(clientReply)).toBeVisible();
await expect(page.getByText("평가 AI가 저장된 축어록을 분석했습니다.")).toBeVisible();
});
});

View file

@ -37,6 +37,31 @@ async function createEndedSession(page: Page) {
return session.session_id;
}
async function expectReviewRegionsReachable(page: Page) {
await expect(page.locator(".sr-overview")).toBeVisible();
await expect(page.locator(".sr-feedback")).toBeVisible();
await expect(page.locator(".sr-card--chart")).toBeVisible();
await expect(page.locator(".sr-card--flow")).toBeVisible();
await expect(page.locator(".sr-card--rubric")).toBeVisible();
await expect(page.locator(".sr-card--transcript")).toBeVisible();
const grid = await page.locator(".sr-cols").evaluate((el) => {
const style = window.getComputedStyle(el);
return {
display: style.display,
areas: style.gridTemplateAreas,
};
});
expect(grid.display).toBe("grid");
expect(grid.areas).toContain("overview");
expect(grid.areas).toContain("feedback");
expect(grid.areas).toContain("transcript");
await expect(page.locator(".sr-overview")).toBeInViewport();
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
}
test.describe("session review", () => {
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
await signInAsLearner(page);
@ -60,6 +85,7 @@ test.describe("session review", () => {
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
await expect(page.getByText("32분 14초")).toHaveCount(0);
await expect(page.getByText("시연")).toHaveCount(0);
await expectReviewRegionsReachable(page);
const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) =>
buttons.map((button) => {
const rect = button.getBoundingClientRect();

View file

@ -112,6 +112,226 @@ function hasEngineConfigRequestBody(engineMode: string, model: string) {
};
}
async function expectNoEngineSegmentClipping(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => {
const issues: Array<{
target: string;
text: string;
inlineOverflow: number;
blockOverflow: number;
leftOverflow: number;
rightOverflow: number;
width: number;
height: number;
}> = [];
const segment = document.querySelector<HTMLElement>("#set-engine .vg-set__seg");
if (!segment) {
return [
{
target: "segment",
text: "missing",
inlineOverflow: 0,
blockOverflow: 0,
leftOverflow: 0,
rightOverflow: 0,
width: 0,
height: 0,
},
];
}
const segmentRect = segment.getBoundingClientRect();
const segmentInlineOverflow = Math.ceil(segment.scrollWidth - segment.clientWidth);
const segmentBlockOverflow = Math.ceil(segment.scrollHeight - segment.clientHeight);
if (segmentInlineOverflow > 1 || segmentBlockOverflow > 1) {
issues.push({
target: "segment",
text: "",
inlineOverflow: segmentInlineOverflow,
blockOverflow: segmentBlockOverflow,
leftOverflow: 0,
rightOverflow: 0,
width: Math.ceil(segmentRect.width),
height: Math.ceil(segmentRect.height),
});
}
for (const button of Array.from(
segment.querySelectorAll<HTMLElement>(".vg-set__seg-btn"),
)) {
const rect = button.getBoundingClientRect();
const inlineOverflow = Math.ceil(button.scrollWidth - button.clientWidth);
const blockOverflow = Math.ceil(button.scrollHeight - button.clientHeight);
const leftOverflow = Math.ceil(segmentRect.left - rect.left);
const rightOverflow = Math.ceil(rect.right - segmentRect.right);
if (
inlineOverflow > 1 ||
blockOverflow > 1 ||
leftOverflow > 1 ||
rightOverflow > 1
) {
issues.push({
target: "button",
text: (button.textContent ?? "").replace(/\s+/g, " ").trim(),
inlineOverflow,
blockOverflow,
leftOverflow,
rightOverflow,
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
}
}
return issues;
}),
)
.toEqual([]);
}
async function expectNoSettingsControlClipping(page: Page) {
await expect
.poll(async () =>
page.evaluate(() => {
const selector = [
".vg-set",
".vg-set__rail",
".vg-set__rail-card",
".vg-set__nav",
".vg-set__nav-item",
".vg-set__forms",
".vg-set__group",
".vg-set__profile",
".vg-set__field",
".vg-set__control-block",
".vg-set__opt",
".vg-set__voice",
".vg-set__range-row",
".vg-set__seg",
".vg-set__seg-btn",
".vg-btn",
".vg-toggle",
].join(",");
const issues: Array<{
target: string;
text: string;
inlineOverflow: number;
blockOverflow: number;
width: number;
height: number;
}> = [];
for (const element of Array.from(document.querySelectorAll<HTMLElement>(selector))) {
const rect = element.getBoundingClientRect();
const style = window.getComputedStyle(element);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
if (!visible) continue;
const inlineOverflow = Math.ceil(element.scrollWidth - element.clientWidth);
const blockOverflow = Math.ceil(element.scrollHeight - element.clientHeight);
const allowsInlineScroll = element.classList.contains("vg-set__nav");
if ((!allowsInlineScroll && inlineOverflow > 1) || blockOverflow > 1) {
issues.push({
target: `.${Array.from(element.classList).join(".") || element.tagName.toLowerCase()}`,
text: (element.textContent ?? "").replace(/\s+/g, " ").trim().slice(0, 80),
inlineOverflow,
blockOverflow,
width: Math.ceil(rect.width),
height: Math.ceil(rect.height),
});
}
}
return issues.slice(0, 8);
}),
)
.toEqual([]);
}
async function expectPracticalSettingsLayout(page: Page, mode: "desktop" | "mobile") {
await expect
.poll(async () =>
page.evaluate((layoutMode) => {
const countColumns = (template: string) =>
template === "none" ? 0 : template.split(" ").filter(Boolean).length;
const root = document.querySelector<HTMLElement>(".vg-set");
const forms = document.querySelector<HTMLElement>(".vg-set__forms");
const railCard = document.querySelector<HTMLElement>(".vg-set__rail-card");
const nav = document.querySelector<HTMLElement>(".vg-set__nav");
const account = document.querySelector<HTMLElement>("#set-account");
const appearance = document.querySelector<HTMLElement>("#set-appearance");
const notify = document.querySelector<HTMLElement>("#set-notify");
const voice = document.querySelector<HTMLElement>("#set-voice");
if (!root || !forms || !railCard || !nav || !account || !appearance || !notify || !voice) {
return { ready: false };
}
const rootColumns = countColumns(getComputedStyle(root).gridTemplateColumns);
const formColumns = countColumns(getComputedStyle(forms).gridTemplateColumns);
const railCardRect = railCard.getBoundingClientRect();
const navRect = nav.getBoundingClientRect();
const accountStyle = getComputedStyle(account);
const appearanceRect = appearance.getBoundingClientRect();
const notifyRect = notify.getBoundingClientRect();
const voiceRect = voice.getBoundingClientRect();
if (layoutMode === "desktop") {
return {
ready: true,
rootColumns,
formColumns,
railCardVisible: railCardRect.height > 24,
shortPanelsShareRow:
Math.abs(appearanceRect.top - notifyRect.top) <= 4 &&
appearanceRect.left < notifyRect.left,
voiceBelowShortPanels:
voiceRect.top > appearanceRect.top && voiceRect.top > notifyRect.top,
};
}
return {
ready: true,
rootColumns,
formColumns,
railCardHidden: railCardRect.height === 0,
navSingleLine: navRect.height <= 54,
compactPanelPadding: Number.parseFloat(accountStyle.paddingTop) <= 14,
};
}, mode),
)
.toEqual(
mode === "desktop"
? {
ready: true,
rootColumns: 2,
formColumns: 2,
railCardVisible: true,
shortPanelsShareRow: true,
voiceBelowShortPanels: true,
}
: {
ready: true,
rootColumns: 1,
formColumns: 1,
railCardHidden: true,
navSingleLine: true,
compactPanelPadding: true,
},
);
}
function slugFor(testInfo: TestInfo) {
let hash = 0;
for (const char of testInfo.title) {
@ -396,6 +616,20 @@ test.describe("settings page", () => {
});
});
test("admin settings use a compact clipping-safe control layout", async ({
page,
}, testInfo) => {
const isMobile = testInfo.project.name.includes("mobile");
const displayName = `Settings Layout Admin ${testInfo.project.name}`;
await signInAs(page, "admin", testInfo, displayName);
await openSettings(page, { admin: true });
await expectPracticalSettingsLayout(page, isMobile ? "mobile" : "desktop");
await expectNoSettingsControlClipping(page);
await expectNoHorizontalOverflow(page);
});
test("admin engine settings panel stays readable at a mobile viewport", async ({
page,
}, testInfo) => {
@ -409,11 +643,13 @@ test.describe("settings page", () => {
const engine = page.locator("#set-engine");
await expect(engine).toBeVisible();
await expect(engine.locator("[data-engine-mode]")).toHaveCount(4);
await expectNoEngineSegmentClipping(page);
await expect(engine.locator("input").nth(0)).toBeVisible();
await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url);
await expect(engine.locator("input").nth(1)).toBeVisible();
await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model);
await expectNoSettingsControlClipping(page);
await expectNoHorizontalOverflow(page);
});
@ -423,6 +659,7 @@ test.describe("settings page", () => {
await openSettings(page, { admin: true });
await expectNoSettingsControlClipping(page);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -16,6 +16,44 @@ async function expectResponseOk(response: { ok: () => boolean; text: () => Promi
}
}
async function expectVisibleButtonsFit(page: Page, selector: string, context: string) {
const clippedButtons = await page.locator(selector).evaluateAll((buttons) =>
buttons
.map((button) => {
const rect = button.getBoundingClientRect();
const owner = button.closest<HTMLElement>(".pf-persona,.pf-panel") ?? button.parentElement;
const ownerRect = owner?.getBoundingClientRect();
const style = window.getComputedStyle(button);
const visible =
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity) !== 0 &&
rect.width > 0 &&
rect.height > 0;
const contentClipped =
button.scrollWidth > button.clientWidth + 1 ||
button.scrollHeight > button.clientHeight + 1;
const outsideOwner = ownerRect
? rect.left < ownerRect.left - 1 ||
rect.right > ownerRect.right + 1 ||
rect.top < ownerRect.top - 1 ||
rect.bottom > ownerRect.bottom + 1
: false;
return {
text: button.textContent?.replace(/\s+/g, " ").trim(),
width: Math.ceil(rect.width),
contentClipped,
outsideOwner,
visible,
};
})
.filter((button) => button.visible && (button.contentClipped || button.outsideOwner)),
);
expect(clippedButtons, `${context}: ${JSON.stringify(clippedButtons)}`).toEqual([]);
}
function isTeacherDashboardResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard");
@ -37,7 +75,99 @@ async function createEndedLearnerSession(page: Page) {
return session.session_id;
}
function recentSessionFixture(index: number) {
const padded = String(index).padStart(2, "0");
return {
session_id: `mobile-readable-session-${padded}-00000000-0000-4000-9000-${padded}${padded}${padded}${padded}${padded}${padded}`,
learner_id: `learner-${padded}`,
learner_label: `E2E Learner ${padded}`,
persona_code: `P-MOBILE-${padded}`,
persona_name: `Responsive persona ${padded}`,
session_no: index,
status: index % 2 === 0 ? "active" : "ended",
stage: index % 2 === 0 ? "intervention-planning" : "rapport-and-assessment",
turn_count: 12 + index,
learner_turn_count: 6 + index,
client_turn_count: 6,
started_at: `2026-06-26T0${index}:10:00Z`,
ended_at: index % 2 === 0 ? null : `2026-06-26T0${index}:45:00Z`,
};
}
test.describe("teacher console", () => {
test("lets a teacher approve a pending persona review from the console @single-run", async ({
page,
}) => {
await signInAsTeacher(page);
const personaId = "00000000-0000-0000-0000-000000009901";
const pendingPersona = {
persona_id: personaId,
code: "P2",
version: 3,
status: "review",
display_name: "검수 대기 페르소나",
difficulty: "moderate",
theory_target: ["humanistic"],
source_provenance: "faculty import",
is_synthetic: true,
created_at: "2026-06-26T07:00:00Z",
approved_at: null,
};
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 0,
active_sessions: 0,
ended_sessions: 0,
pending_reviews: [],
recent_sessions: [],
message: "검토할 실제 회기가 없습니다.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([pendingPersona]),
}),
);
await page.route(`**/api/personas/review/${personaId}`, async (route) => {
expect(route.request().method()).toBe("POST");
expect(route.request().postDataJSON()).toEqual({ action: "approve" });
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...pendingPersona,
status: "approved",
approved_at: "2026-06-26T07:01:00Z",
}),
});
});
await page.goto("/teach");
const row = page.locator('[data-persona-review-row="true"]').filter({
hasText: "검수 대기 페르소나",
});
await expect(row).toBeVisible();
await expect(row.getByText("검수 대기", { exact: true })).toBeVisible();
await expectVisibleButtonsFit(page, ".pf-persona__actions .vg-btn", "persona review actions");
await row.getByRole("button", { name: "승인" }).click();
await expect(row).toHaveCount(0);
await expect(page.getByText("검수 대기 페르소나 없음")).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
@ -59,23 +189,98 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("keeps recent sessions readable without horizontal scrolling across breakpoints", async ({
page,
}) => {
const recentSessions = [1, 2, 3].map(recentSessionFixture);
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: recentSessions.length,
active_sessions: 1,
ended_sessions: 2,
pending_reviews: [],
recent_sessions: recentSessions,
message: "Fixture-backed recent session layout check.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-recent-list")).toBeVisible();
await expect(page.locator('[data-recent-session-row="true"]')).toHaveCount(
recentSessions.length,
);
await expectNoHorizontalOverflow(page);
const metrics = await page.evaluate(() => {
const doc = document.documentElement;
const list = document.querySelector<HTMLElement>(".pf-recent-list");
const header = document.querySelector<HTMLElement>(".pf-recent-head");
const row = document.querySelector<HTMLElement>('[data-recent-session-row="true"]');
const cells = Array.from(row?.querySelectorAll<HTMLElement>(".pf-recent__cell") ?? []);
if (!list || !header || !row || cells.length === 0) {
throw new Error("recent sessions list was not rendered");
}
const listRect = list.getBoundingClientRect();
const rowRect = row.getBoundingClientRect();
return {
viewportWidth: doc.clientWidth,
listOverflowX: Math.ceil(list.scrollWidth - list.clientWidth),
headerDisplay: window.getComputedStyle(header).display,
headerPosition: window.getComputedStyle(header).position,
rowDisplay: window.getComputedStyle(row).display,
gridCellCount: cells.filter((cell) => window.getComputedStyle(cell).display === "grid").length,
labels: cells.map((cell) => cell.getAttribute("data-label")),
rowRight: Math.ceil(rowRect.right),
listRight: Math.ceil(listRect.right),
};
});
expect(metrics.listOverflowX).toBeLessThanOrEqual(1);
expect(metrics.rowRight).toBeLessThanOrEqual(metrics.listRight + 1);
if (metrics.viewportWidth <= 860) {
expect(metrics.headerDisplay).toBe("none");
expect(metrics.rowDisplay).toBe("grid");
expect(metrics.gridCellCount).toBe(6);
expect(metrics.labels).toEqual(["페르소나", "상태", "단계", "턴", "시작", "종료"]);
} else {
expect(metrics.headerDisplay).toBe("grid");
expect(metrics.headerPosition).toBe("sticky");
expect(metrics.rowDisplay).toBe("grid");
expect(metrics.gridCellCount).toBe(0);
}
});
test("keeps long teacher lists in bounded panels", async ({ page }) => {
await createEndedLearnerSession(page);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-list")).toBeVisible();
await expect(page.locator(".pf-tablewrap")).toBeVisible();
await expect(page.locator(".pf-recent-list")).toBeVisible();
const metrics = await page.evaluate(() => {
const list = document.querySelector<HTMLElement>(".pf-list");
const table = document.querySelector<HTMLElement>(".pf-tablewrap");
const header = document.querySelector<HTMLElement>(".pf-table th");
if (!list || !table || !header) {
const recent = document.querySelector<HTMLElement>(".pf-recent-list");
const header = document.querySelector<HTMLElement>(".pf-recent-head");
if (!list || !recent || !header) {
throw new Error("teacher list panels were not rendered");
}
const listStyle = window.getComputedStyle(list);
const tableStyle = window.getComputedStyle(table);
const recentStyle = window.getComputedStyle(recent);
const headerStyle = window.getComputedStyle(header);
const doc = document.documentElement;
return {
@ -83,21 +288,68 @@ test.describe("teacher console", () => {
viewportHeight: doc.clientHeight,
listMaxHeight: listStyle.maxHeight,
listOverflowY: listStyle.overflowY,
tableMaxHeight: tableStyle.maxHeight,
tableOverflowY: tableStyle.overflowY,
recentMaxHeight: recentStyle.maxHeight,
recentOverflowY: recentStyle.overflowY,
headerPosition: headerStyle.position,
};
});
expect(metrics.listMaxHeight).not.toBe("none");
expect(metrics.tableMaxHeight).not.toBe("none");
expect(metrics.recentMaxHeight).not.toBe("none");
expect(["auto", "scroll"]).toContain(metrics.listOverflowY);
expect(["auto", "scroll"]).toContain(metrics.tableOverflowY);
expect(["auto", "scroll"]).toContain(metrics.recentOverflowY);
expect(metrics.headerPosition).toBe("sticky");
expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200);
await expectNoHorizontalOverflow(page);
});
test("keeps persona review actions contained on mobile", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 1,
active_sessions: 0,
ended_sessions: 0,
pending_reviews: [],
recent_sessions: [],
message: "Mobile review action layout check.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([
{
persona_id: "00000000-0000-0000-0000-000000009902",
code: "P-LONG-MOBILE",
version: 11,
status: "review",
display_name: "아주 긴 이름의 모바일 검수 대상 페르소나",
difficulty: "advanced",
theory_target: ["humanistic", "cognitive-behavioral"],
source_provenance: "mobile action clipping fixture",
is_synthetic: true,
created_at: "2026-06-26T07:00:00Z",
approved_at: null,
},
]),
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator('[data-persona-review-row="true"]')).toBeVisible();
await expectNoHorizontalOverflow(page);
await expectVisibleButtonsFit(page, ".pf-persona__actions .vg-btn", "mobile persona review actions");
});
test("denies learner access to the teacher dashboard API and UI", async ({ page }) => {
await signInAsLearner(page);

View file

@ -16,12 +16,35 @@ interface SpawnedApi {
stop: () => Promise<void>;
}
interface SpawnedWeb {
baseURL: string;
logs: () => string;
stop: () => Promise<void>;
}
interface VoiceProbe {
code: number;
messages: string[];
binaryChunks: number;
}
interface VoiceUiProbeMessage {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
}
interface VoiceUiProbeState {
getUserMediaCalls: number;
recorderStarts: number;
recorderStops: number;
trackStops: number;
audioPlays: number;
messages: VoiceUiProbeMessage[];
closeEvents: number[];
}
// This fixture intentionally starts a DB-offline API with ALLOW_SEED_PERSONA_FALLBACK=true
// so the voice provider cascade can be exercised without a Postgres dependency.
const SEEDED_VOICE_PERSONA_CODE = "P1";
@ -137,9 +160,11 @@ async function waitForApi(baseURL: string, proc: ChildProcessWithoutNullStreams)
async function startApi({
engineURL,
openAIBaseURL,
frontendBaseURL = "http://localhost:5173",
}: {
engineURL: string;
openAIBaseURL: string;
frontendBaseURL?: string;
}): Promise<SpawnedApi> {
const port = await freePort();
const baseURL = `http://127.0.0.1:${port}`;
@ -181,8 +206,8 @@ async function startApi({
ENGINE_CONNECT_TIMEOUT: "2",
OPENAI_API_KEY: "e2e-fake-key",
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
FRONTEND_BASE_URL: "http://localhost:5173",
CORS_ORIGINS: '["http://localhost:5173"]',
FRONTEND_BASE_URL: frontendBaseURL,
CORS_ORIGINS: JSON.stringify([frontendBaseURL]),
},
windowsHide: true,
},
@ -215,6 +240,73 @@ async function startApi({
};
}
async function waitForWeb(baseURL: string, proc: ChildProcessWithoutNullStreams): Promise<void> {
const started = Date.now();
let lastError = "";
while (Date.now() - started < 30_000) {
if (proc.exitCode !== null) {
throw new Error(`Web exited early with code ${proc.exitCode}: ${lastError}`);
}
try {
const response = await fetch(baseURL);
if (response.ok) return;
lastError = await response.text();
} catch (err) {
lastError = err instanceof Error ? err.message : String(err);
}
await new Promise((resolve) => setTimeout(resolve, 250));
}
throw new Error(`Timed out waiting for web ${baseURL}: ${lastError}`);
}
async function startWeb({
apiBaseURL,
port,
}: {
apiBaseURL: string;
port: number;
}): Promise<SpawnedWeb> {
const baseURL = `http://127.0.0.1:${port}`;
const proc = spawn(
process.execPath,
["node_modules/vite/bin/vite.js", "--host", "127.0.0.1", "--port", String(port)],
{
cwd: ".",
env: {
...process.env,
VITE_API_BASE: apiBaseURL,
},
windowsHide: true,
},
);
let logs = "";
proc.stdout.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
proc.stderr.on("data", (chunk) => {
logs += String(chunk).slice(-4000);
});
await waitForWeb(baseURL, proc).catch((err) => {
proc.kill();
throw new Error(`${err instanceof Error ? err.message : String(err)}\n${logs}`);
});
return {
baseURL,
logs: () => logs,
stop: async () => {
if (proc.exitCode === null) proc.kill();
await new Promise<void>((resolve) => {
if (proc.exitCode !== null) {
resolve();
return;
}
proc.once("exit", () => resolve());
setTimeout(resolve, 3000);
});
},
};
}
async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: string): Promise<VoiceProbe> {
return page.evaluate(
({ apiBase, sid }) =>
@ -264,6 +356,182 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
);
}
async function installSyntheticVoiceCapture(page: Page): Promise<void> {
await page.addInitScript(() => {
type ProbeMessage = {
direction: "sent" | "received";
kind: "text" | "binary";
data?: string;
byteLength?: number;
};
type ProbeState = {
getUserMediaCalls: number;
recorderStarts: number;
recorderStops: number;
trackStops: number;
audioPlays: number;
messages: ProbeMessage[];
closeEvents: number[];
};
const w = window as Window & { __voiceUiProbe?: ProbeState };
const probe: ProbeState = {
getUserMediaCalls: 0,
recorderStarts: 0,
recorderStops: 0,
trackStops: 0,
audioPlays: 0,
messages: [],
closeEvents: [],
};
w.__voiceUiProbe = probe;
const fakeTrack = {
kind: "audio",
readyState: "live",
stop() {
probe.trackStops += 1;
this.readyState = "ended";
},
};
const fakeStream = {
id: "synthetic-voice-ui-stream",
active: true,
getTracks: () => [fakeTrack],
getAudioTracks: () => [fakeTrack],
};
Object.defineProperty(navigator, "mediaDevices", {
configurable: true,
value: {
getUserMedia: async () => {
probe.getUserMediaCalls += 1;
return fakeStream;
},
},
});
class FakeMediaRecorder extends EventTarget {
static isTypeSupported() {
return true;
}
state = "inactive";
mimeType: string;
private timer: number | null = null;
ondataavailable: ((event: Event & { data: Blob }) => void) | null = null;
onstop: ((event: Event) => void) | null = null;
constructor(_stream: unknown, options?: { mimeType?: string }) {
super();
this.mimeType = options?.mimeType ?? "audio/webm";
}
start(timeslice?: number) {
this.state = "recording";
probe.recorderStarts += 1;
const emit = () => {
if (this.state !== "recording") return;
const data = new Blob([new Uint8Array([1, 2, 3, 4, 5, 6])], {
type: this.mimeType || "audio/webm",
});
const event = new Event("dataavailable") as Event & { data: Blob };
Object.defineProperty(event, "data", { value: data });
this.ondataavailable?.(event);
this.dispatchEvent(event);
};
window.setTimeout(emit, 25);
if (timeslice && timeslice > 0) {
this.timer = window.setInterval(emit, timeslice);
}
}
stop() {
if (this.state === "inactive") return;
this.state = "inactive";
if (this.timer !== null) {
window.clearInterval(this.timer);
this.timer = null;
}
probe.recorderStops += 1;
const event = new Event("stop");
this.onstop?.(event);
this.dispatchEvent(event);
}
}
Object.defineProperty(window, "MediaRecorder", {
configurable: true,
value: FakeMediaRecorder,
});
const NativeWebSocket = window.WebSocket;
const sizeOf = (data: unknown) => {
if (typeof data === "string") return data.length;
if (data instanceof Blob) return data.size;
if (data instanceof ArrayBuffer) return data.byteLength;
if (ArrayBuffer.isView(data)) return data.byteLength;
return 0;
};
class ProbeWebSocket extends NativeWebSocket {
constructor(url: string | URL, protocols?: string | string[]) {
if (protocols === undefined) super(url);
else super(url, protocols);
this.addEventListener("message", (event) => {
if (typeof event.data === "string") {
probe.messages.push({ direction: "received", kind: "text", data: event.data });
} else {
probe.messages.push({
direction: "received",
kind: "binary",
byteLength: sizeOf(event.data),
});
}
});
this.addEventListener("close", (event) => {
probe.closeEvents.push(event.code);
});
}
send(data: string | ArrayBufferLike | Blob | ArrayBufferView) {
if (typeof data === "string") {
probe.messages.push({ direction: "sent", kind: "text", data });
} else {
probe.messages.push({ direction: "sent", kind: "binary", byteLength: sizeOf(data) });
}
return super.send(data);
}
}
for (const key of ["CONNECTING", "OPEN", "CLOSING", "CLOSED"] as const) {
Object.defineProperty(ProbeWebSocket, key, { value: NativeWebSocket[key] });
}
Object.defineProperty(window, "WebSocket", {
configurable: true,
value: ProbeWebSocket,
});
HTMLMediaElement.prototype.play = function patchedPlay() {
probe.audioPlays += 1;
window.setTimeout(() => {
this.dispatchEvent(new Event("ended"));
}, 120);
return Promise.resolve();
};
});
}
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
return page.evaluate(() => {
const probe = (window as Window & { __voiceUiProbe?: VoiceUiProbeState }).__voiceUiProbe;
if (!probe) throw new Error("voice UI probe was not installed");
return probe;
});
}
async function parsedVoiceUiEvents(page: Page): Promise<Array<{ type?: string; [key: string]: unknown }>> {
const probe = await readVoiceUiProbe(page);
return probe.messages
.filter((message) => message.direction === "received" && message.kind === "text" && message.data)
.map((message) => JSON.parse(message.data ?? "{}") as { type?: string; [key: string]: unknown });
}
test.describe("voice cascade success path", () => {
test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({
page,
@ -357,4 +625,188 @@ test.describe("voice cascade success path", () => {
await openai.close();
}
});
test("drives one voice turn through the Session mic UI with synthetic browser audio @single-run", async ({
page,
}, testInfo) => {
test.setTimeout(90_000);
const diagnostics: string[] = [];
page.on("pageerror", (error) => diagnostics.push(`pageerror: ${error.message}`));
page.on("console", (message) => {
if (message.type() === "error") diagnostics.push(`console: ${message.text()}`);
});
page.on("requestfailed", (request) => {
diagnostics.push(`requestfailed: ${request.method()} ${request.url()} ${request.failure()?.errorText ?? ""}`);
});
page.on("response", (response) => {
const url = response.url();
if (response.status() >= 400 && (url.includes("/sessions") || url.includes("/voice/ws"))) {
diagnostics.push(`response: ${response.status()} ${url}`);
}
});
await installSyntheticVoiceCapture(page);
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
const webPort = await freePort();
const webBaseURL = `http://127.0.0.1:${webPort}`;
const api = await startApi({
engineURL: engine.url,
openAIBaseURL: openai.url,
frontendBaseURL: webBaseURL,
});
const web = await startWeb({ apiBaseURL: api.baseURL, port: webPort });
try {
await page.route("**/personas", async (route) => {
if (route.request().method() !== "GET") {
await route.fallback();
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
headers: {
"access-control-allow-origin": web.baseURL,
"access-control-allow-credentials": "true",
},
body: JSON.stringify([
{
code: SEEDED_VOICE_PERSONA_CODE,
display_name: "Voice UI fixture",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "teen" },
presenting_summary: "Synthetic browser audio UI proof",
voice_preset: "soft-young-fem",
source: "database",
degraded: false,
},
]),
});
});
await page.goto(`${web.baseURL}/login`, { waitUntil: "domcontentloaded" });
const browserLogin = await page.evaluate(async ({ apiBase, workerIndex }) => {
const login = await fetch(`${apiBase}/auth/dev-login`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
email: `voice-ui.${workerIndex}@hs.ac.kr`,
role: "learner",
display_name: "Voice UI",
}),
});
const loginBody = await login.text();
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
return { ok: false, step: "me", status: me.status, body: meBody };
}
return { ok: true, me: JSON.parse(meBody) as unknown };
}, {
apiBase: api.baseURL,
workerIndex: testInfo.workerIndex,
});
expect(browserLogin, api.logs()).toMatchObject({ ok: true });
await page.goto(`${web.baseURL}/learn/session/${SEEDED_VOICE_PERSONA_CODE}`);
await expect(
page.locator(".sx-prestart__actions button").first(),
diagnostics.join("\n") || (await page.locator("#root").innerText().catch(() => "")),
).toBeVisible();
await page.locator(".sx-prestart__actions button").first().click();
await expect(
page.locator(".sx-page.sx-page--active"),
[
...diagnostics,
`apiLogs=${api.logs()}`,
`pageText=${await page.locator("#root").innerText().catch(() => "")}`,
].join("\n\n"),
).toBeVisible({ timeout: 20_000 });
const mic = page.locator(".sx-mic");
await expect(mic).toBeEnabled();
await mic.click();
await expect
.poll(async () => (await readVoiceUiProbe(page)).getUserMediaCalls, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => (await readVoiceUiProbe(page)).recorderStarts, { timeout: 10_000 })
.toBeGreaterThan(0);
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_start"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) => message.direction === "sent" && message.kind === "binary",
);
}, { timeout: 10_000 })
.toBeTruthy();
await mic.click();
await expect
.poll(async () => {
const probe = await readVoiceUiProbe(page);
return probe.messages.some(
(message) =>
message.direction === "sent" &&
message.kind === "text" &&
message.data?.includes('"audio_end"'),
);
}, { timeout: 10_000 })
.toBeTruthy();
await expect
.poll(async () => {
const events = await parsedVoiceUiEvents(page);
return {
transcript: events.some((event) => event.type === "transcript"),
reply: events.some((event) => event.type === "reply"),
ttsEnd: events.some((event) => event.type === "tts_end"),
errors: events.filter((event) => event.type === "error" || event.type === "degraded"),
};
}, { timeout: 30_000 })
.toEqual({ transcript: true, reply: true, ttsEnd: true, errors: [] });
const events = await parsedVoiceUiEvents(page);
const transcript = events.find((event) => event.type === "transcript")?.text;
const reply = events.find((event) => event.type === "reply")?.text;
expect(typeof transcript).toBe("string");
expect(typeof reply).toBe("string");
await expect(page.locator(".sx-utt").filter({ hasText: String(transcript) })).toBeVisible();
await expect(page.locator(".sx-utt").filter({ hasText: String(reply) })).toBeVisible();
const probe = await readVoiceUiProbe(page);
expect(probe.audioPlays).toBeGreaterThan(0);
expect(probe.messages.some((message) => message.direction === "received" && message.kind === "binary")).toBe(
true,
);
expect(openai.requests()).toEqual(
expect.arrayContaining(["POST /v1/audio/transcriptions", "POST /v1/audio/speech"]),
);
expect(engine.requests()).toContain("POST /v1/generate");
} finally {
await web.stop();
await api.stop();
await engine.close();
await openai.close();
}
});
});

View file

@ -1,4 +1,6 @@
export function onRequest() {
const FIRST_PARTY_PERSONA_PATH = /^personas\/p[4-7]\/[a-z0-9._/-]+$/i;
function notFound() {
return new Response("Live2D demo assets are not part of this deployment.", {
status: 404,
headers: {
@ -8,3 +10,38 @@ export function onRequest() {
},
});
}
function normalizePathParam(value) {
if (Array.isArray(value)) return value.join("/");
return String(value ?? "");
}
export async function onRequest(context) {
const requestedPath = normalizePathParam(context.params?.path)
.replace(/^\/+/, "")
.replace(/\\/g, "/");
if (requestedPath.split("/").some((segment) => segment === "." || segment === "..")) {
return notFound();
}
if (!FIRST_PARTY_PERSONA_PATH.test(requestedPath)) {
return notFound();
}
const assetResponse = context.env?.ASSETS
? await context.env.ASSETS.fetch(context.request)
: null;
if (!assetResponse || assetResponse.status === 404) {
return notFound();
}
const headers = new Headers(assetResponse.headers);
headers.set("cache-control", "public, max-age=300");
headers.set("x-robots-tag", "noindex");
return new Response(assetResponse.body, {
status: assetResponse.status,
headers,
});
}

View file

@ -7,6 +7,7 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
"preview": "vite preview",
"typecheck": "tsc -b",
"lint": "tsc -b",

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 456 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 64 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 212 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 200 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

View file

@ -0,0 +1,30 @@
{
"schemaVersion": "vignette.live2d.assets.v1",
"generatedAt": "1970-01-01T00:00:00.000Z",
"personas": [
{
"code": "P4",
"modelId": "vignette-p4-live2d",
"model3": "/live2d/personas/p4/p4.model3.json",
"expressionCount": 28
},
{
"code": "P5",
"modelId": "vignette-p5-live2d",
"model3": "/live2d/personas/p5/p5.model3.json",
"expressionCount": 28
},
{
"code": "P6",
"modelId": "vignette-p6-live2d",
"model3": "/live2d/personas/p6/p6.model3.json",
"expressionCount": 28
},
{
"code": "P7",
"modelId": "vignette-p7-live2d",
"model3": "/live2d/personas/p7/p7.model3.json",
"expressionCount": 28
}
]
}

View file

@ -0,0 +1,93 @@
{
"Type": "Live2D Expression",
"Version": 3,
"FadeInTime": 0.26,
"FadeOutTime": 0.32,
"Parameters": [
{
"Id": "ParamAngleX",
"Value": -3.24,
"Blend": "Add"
},
{
"Id": "ParamAngleY",
"Value": -0.432,
"Blend": "Add"
},
{
"Id": "ParamAngleZ",
"Value": -4.32,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleX",
"Value": -5.184,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleZ",
"Value": 3.888,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 0.994,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeROpen",
"Value": 0.994,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeBallX",
"Value": -0.144,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": -0.087,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": -0.022,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -0.022,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0.064,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": -0.064,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -0.309,
"Blend": "Overwrite"
},
{
"Id": "ParamMouthOpenY",
"Value": 0.102,
"Blend": "Overwrite"
},
{
"Id": "ParamBreath",
"Value": 0.64,
"Blend": "Overwrite"
}
],
"Vignette": {
"Label": "분노",
"Group": "defensive",
"Duration": 0.38
}
}

View file

@ -0,0 +1,93 @@
{
"Type": "Live2D Expression",
"Version": 3,
"FadeInTime": 0.26,
"FadeOutTime": 0.32,
"Parameters": [
{
"Id": "ParamAngleX",
"Value": -10.8,
"Blend": "Add"
},
{
"Id": "ParamAngleY",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamAngleZ",
"Value": 3.456,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleX",
"Value": -2.592,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleZ",
"Value": 1.944,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 1.1,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeROpen",
"Value": 1.1,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeBallX",
"Value": -0.48,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": 0.048,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": 0.18,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": 0.18,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": -0.445,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0.445,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -0.026,
"Blend": "Overwrite"
},
{
"Id": "ParamMouthOpenY",
"Value": 0.004,
"Blend": "Overwrite"
},
{
"Id": "ParamBreath",
"Value": 0.6,
"Blend": "Overwrite"
}
],
"Vignette": {
"Label": "불안",
"Group": "negative",
"Duration": 0.68
}
}

View file

@ -0,0 +1,93 @@
{
"Type": "Live2D Expression",
"Version": 3,
"FadeInTime": 0.26,
"FadeOutTime": 0.32,
"Parameters": [
{
"Id": "ParamAngleX",
"Value": -21.6,
"Blend": "Add"
},
{
"Id": "ParamAngleY",
"Value": -7.776,
"Blend": "Add"
},
{
"Id": "ParamAngleZ",
"Value": -12.096,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleX",
"Value": -5.184,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleZ",
"Value": 3.888,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 0.703,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeROpen",
"Value": 0.703,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeBallX",
"Value": -0.96,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": -0.654,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": -0.389,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -0.389,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": -0.731,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0.731,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -0.386,
"Blend": "Overwrite"
},
{
"Id": "ParamMouthOpenY",
"Value": 0.053,
"Blend": "Overwrite"
},
{
"Id": "ParamBreath",
"Value": 0.48,
"Blend": "Overwrite"
}
],
"Vignette": {
"Label": "수치심",
"Group": "negative",
"Duration": 0.68
}
}

View file

@ -0,0 +1,93 @@
{
"Type": "Live2D Expression",
"Version": 3,
"FadeInTime": 0.26,
"FadeOutTime": 0.32,
"Parameters": [
{
"Id": "ParamAngleX",
"Value": -10.8,
"Blend": "Add"
},
{
"Id": "ParamAngleY",
"Value": -6.912,
"Blend": "Add"
},
{
"Id": "ParamAngleZ",
"Value": 8.64,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleX",
"Value": -2.592,
"Blend": "Add"
},
{
"Id": "ParamBodyAngleZ",
"Value": 1.944,
"Blend": "Add"
},
{
"Id": "ParamEyeLOpen",
"Value": 0.716,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeROpen",
"Value": 0.716,
"Blend": "Overwrite"
},
{
"Id": "ParamEyeBallX",
"Value": -0.48,
"Blend": "Add"
},
{
"Id": "ParamEyeBallY",
"Value": -0.312,
"Blend": "Add"
},
{
"Id": "ParamBrowLY",
"Value": -0.634,
"Blend": "Add"
},
{
"Id": "ParamBrowRY",
"Value": -0.634,
"Blend": "Add"
},
{
"Id": "ParamBrowLAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamBrowRAngle",
"Value": 0,
"Blend": "Add"
},
{
"Id": "ParamMouthForm",
"Value": -0.051,
"Blend": "Overwrite"
},
{
"Id": "ParamMouthOpenY",
"Value": 0.007,
"Blend": "Overwrite"
},
{
"Id": "ParamBreath",
"Value": 0.4,
"Blend": "Overwrite"
}
],
"Vignette": {
"Label": "무심함",
"Group": "energy",
"Duration": 0.94
}
}

Some files were not shown because too many files have changed in this diff Show more