대시보드 폴드아웃/드릴다운 정리 + 페르소나 역린·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
|
|
@ -27,7 +27,7 @@ AUTO_SEED_PERSONAS=false
|
||||||
ALLOW_SEED_PERSONA_FALLBACK=false
|
ALLOW_SEED_PERSONA_FALLBACK=false
|
||||||
EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false
|
EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false
|
||||||
FRONTEND_BASE_URL=http://localhost:5173
|
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.
|
# Live2D runtime. Models must be configured per persona via live2dModelUrl.
|
||||||
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js
|
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js
|
||||||
|
|
|
||||||
3
.gitignore
vendored
|
|
@ -47,3 +47,6 @@ apps/api/gateway.restart.*
|
||||||
apps/api/e2e_*.py
|
apps/api/e2e_*.py
|
||||||
apps/web/_pptr_check.cjs
|
apps/web/_pptr_check.cjs
|
||||||
apps/web/_pptr*.cjs
|
apps/web/_pptr*.cjs
|
||||||
|
|
||||||
|
# 로컬 dev 서버 로그(scripts/dev-up.ps1)
|
||||||
|
.devlogs/
|
||||||
|
|
|
||||||
55
AGENT.md
Normal 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
|
|
@ -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
|
|
@ -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` | 시스템 마스터플랜 (아키텍처·로드맵·스택) |
|
| `apps/api/` | FastAPI/Python 백엔드. 오케스트레이터·페르소나·저항엔진·마스킹·음성·평가·인증. 엔진 게이트웨이(`apps/api/engine_gateway/`) 포함 |
|
||||||
| `docs/redteam/REDTEAM_FINDINGS.md` | 적대검증 40결함 |
|
| `apps/web/` | React 19 + Vite 프론트엔드(3역할: 관리자/교수자/학습자). Playwright E2E |
|
||||||
| `docs/redteam/MASTERPLAN_REVISIONS.md` | 재설계 패치 (claude -p 1급 엔진 복원 등) |
|
| `docs/` | 설계·운영 문서. `docs/dev_dashboard.html`이 SSOT(단일 진실 공급원) |
|
||||||
| `docs/MEMORY_KNOWLEDGE_PERSONA_DESIGN.md` | 메모리·지식·페르소나 (회기 간 연속성) |
|
| `infra/` | Docker Compose 스택(`db`=pgvector pg16, `api`, `web`, `rag`, `proxy`=Caddy) 및 `.env.example` |
|
||||||
| `docs/DESIGN_CONCEPT.md` | 디자인 컨셉 (토큰·아바타·화면) |
|
| `scripts/` | 운영 스크립트(PowerShell/Python): 공개 런타임 기동·감시, 엔진 게이트웨이 프로브, Postgres RLS 감사 등 |
|
||||||
| `docs/mockups/` | 화면 레이아웃 시안 (HTML) |
|
|
||||||
|
|
||||||
## 확정 스택
|
---
|
||||||
|
|
||||||
- **엔진**: 로컬 Opus 4.8 `claude -p` 상주 멀티턴 풀(`--input-format stream-json`), Anthropic Messages API 폴백
|
## 2. 빠른 시작 (로컬 dev)
|
||||||
- **프론트**: 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 외부노출(교수 테스트)
|
|
||||||
|
|
||||||
## 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 관련 문구 금지.
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,19 @@ def _is_local_url(value: str) -> bool:
|
||||||
return host in {"localhost", "127.0.0.1", "::1"}
|
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):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|
@ -106,6 +119,22 @@ class Settings(BaseSettings):
|
||||||
default=False,
|
default=False,
|
||||||
validation_alias="AUTH_DEV_LOGIN_ENABLED",
|
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_affiliation: str = Field(
|
||||||
default="",
|
default="",
|
||||||
validation_alias="DEFAULT_AFFILIATION",
|
validation_alias="DEFAULT_AFFILIATION",
|
||||||
|
|
@ -160,11 +189,23 @@ class Settings(BaseSettings):
|
||||||
forbidden.append("SESSION_SECRET")
|
forbidden.append("SESSION_SECRET")
|
||||||
if _is_local_url(self.frontend_base_url):
|
if _is_local_url(self.frontend_base_url):
|
||||||
forbidden.append("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")
|
forbidden.append("CORS_ORIGINS")
|
||||||
if forbidden:
|
if forbidden:
|
||||||
joined = ", ".join(forbidden)
|
joined = ", ".join(forbidden)
|
||||||
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
|
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
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -39,8 +39,6 @@ class GenerateRequest(BaseModel):
|
||||||
|
|
||||||
ai_role: AIRole
|
ai_role: AIRole
|
||||||
messages: list[EngineMessage]
|
messages: list[EngineMessage]
|
||||||
# tier 라우팅 힌트: client=Sonnet/Solar, evaluator=Opus, fast=Haiku (마스터플랜 §5)
|
|
||||||
tier: Literal["client", "feedback", "fast"] = "client"
|
|
||||||
model: Optional[str] = None # 명시 시 게이트웨이 override
|
model: Optional[str] = None # 명시 시 게이트웨이 override
|
||||||
max_tokens: int = 1024
|
max_tokens: int = 1024
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
import json
|
import json
|
||||||
import uuid
|
import uuid
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable, Literal, cast
|
||||||
|
|
||||||
from .config import settings
|
from .config import settings
|
||||||
from .db import acquire, get_pool
|
from .db import acquire, get_pool
|
||||||
|
|
@ -18,6 +18,7 @@ from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
|
||||||
|
|
||||||
|
|
||||||
SEED_VERSION = 1
|
SEED_VERSION = 1
|
||||||
|
PersonaStatus = Literal["draft", "review", "approved", "archived"]
|
||||||
|
|
||||||
_CARD_COLUMNS = """
|
_CARD_COLUMNS = """
|
||||||
persona_id, code, version, status, display_name, difficulty, theory_target,
|
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
|
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)
|
@dataclass(frozen=True, slots=True)
|
||||||
class CatalogPersona:
|
class CatalogPersona:
|
||||||
|
|
@ -35,6 +45,21 @@ class CatalogPersona:
|
||||||
degraded: bool = False
|
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:
|
def seed_persona_id(code: str) -> str:
|
||||||
return str(uuid.uuid5(uuid.NAMESPACE_URL, f"vignette:persona:{code.upper()}"))
|
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]
|
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:
|
def card_from_row(row: Any) -> PersonaCard:
|
||||||
return PersonaCard(
|
return PersonaCard(
|
||||||
code=str(row["code"]).upper(),
|
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:
|
def seed_fallback_persona(code: str) -> CatalogPersona | None:
|
||||||
card = get_seed_persona(code)
|
card = get_seed_persona(code)
|
||||||
if card is None:
|
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
|
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]:
|
async def list_catalog_personas() -> list[CatalogPersona]:
|
||||||
try:
|
try:
|
||||||
return await list_approved_personas()
|
return await list_approved_personas()
|
||||||
|
|
@ -229,6 +375,9 @@ async def get_catalog_persona(code: str) -> CatalogPersona | None:
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"CatalogPersona",
|
"CatalogPersona",
|
||||||
|
"PersonaReviewItem",
|
||||||
|
"PersonaReviewAction",
|
||||||
|
"PersonaStatus",
|
||||||
"SEED_VERSION",
|
"SEED_VERSION",
|
||||||
"card_from_row",
|
"card_from_row",
|
||||||
"catalog_persona_from_row",
|
"catalog_persona_from_row",
|
||||||
|
|
@ -236,8 +385,11 @@ __all__ = [
|
||||||
"get_catalog_persona",
|
"get_catalog_persona",
|
||||||
"list_approved_personas",
|
"list_approved_personas",
|
||||||
"list_catalog_personas",
|
"list_catalog_personas",
|
||||||
|
"list_persona_review_queue",
|
||||||
"materialize_seed_personas",
|
"materialize_seed_personas",
|
||||||
|
"persona_review_item_from_row",
|
||||||
"seed_fallback_persona",
|
"seed_fallback_persona",
|
||||||
"seed_fallback_personas",
|
"seed_fallback_personas",
|
||||||
"seed_persona_id",
|
"seed_persona_id",
|
||||||
|
"update_persona_review_status",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,13 @@ from pydantic import BaseModel
|
||||||
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
|
from ..auth_sessions import InactiveUserError, SessionUser, create_session, revoke_session
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..deps import CurrentPrincipal, Principal, Role
|
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"])
|
router = APIRouter(prefix="/auth", tags=["auth"])
|
||||||
|
|
||||||
|
|
@ -41,7 +48,15 @@ class OAuthState:
|
||||||
created_at: float
|
created_at: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class SamlState:
|
||||||
|
request_id: str
|
||||||
|
next_path: str
|
||||||
|
created_at: float
|
||||||
|
|
||||||
|
|
||||||
_oauth_states: dict[str, OAuthState] = {}
|
_oauth_states: dict[str, OAuthState] = {}
|
||||||
|
_saml_states: dict[str, SamlState] = {}
|
||||||
|
|
||||||
|
|
||||||
class MeResponse(BaseModel):
|
class MeResponse(BaseModel):
|
||||||
|
|
@ -52,8 +67,17 @@ class MeResponse(BaseModel):
|
||||||
cohort_ids: list[str]
|
cohort_ids: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
class AuthProviderStatus(BaseModel):
|
||||||
|
provider: Literal["google", "saml"]
|
||||||
|
configured: bool
|
||||||
|
enabled: bool
|
||||||
|
login_path: str
|
||||||
|
|
||||||
|
|
||||||
class AuthConfigResponse(BaseModel):
|
class AuthConfigResponse(BaseModel):
|
||||||
google_oauth_configured: bool
|
google_oauth_configured: bool
|
||||||
|
saml_configured: bool
|
||||||
|
providers: list[AuthProviderStatus]
|
||||||
allowed_email_domains: list[str]
|
allowed_email_domains: list[str]
|
||||||
redirect_uri: str
|
redirect_uri: str
|
||||||
dev_login_enabled: bool
|
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))}
|
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]:
|
def allowed_email_domains() -> set[str]:
|
||||||
"""Configured login email domains, normalized for claim checks."""
|
"""Configured login email domains, normalized for claim checks."""
|
||||||
return {
|
return {
|
||||||
|
|
@ -137,6 +192,15 @@ def _role_for_email(email: str) -> Role:
|
||||||
return Role.LEARNER
|
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:
|
def _safe_next_path(next_path: str | None) -> str:
|
||||||
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
|
if not next_path or not next_path.startswith("/") or next_path.startswith("//"):
|
||||||
return "/"
|
return "/"
|
||||||
|
|
@ -211,6 +275,13 @@ def _prune_oauth_states() -> None:
|
||||||
_oauth_states.pop(key, 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:
|
def _cookie_secure() -> bool:
|
||||||
# The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome
|
# The __Host- prefix requires Secure, Path=/, and no Domain. Modern Chrome
|
||||||
# accepts Secure cookies on localhost, which keeps dev and prod semantics
|
# 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)
|
@router.get("/config", response_model=AuthConfigResponse)
|
||||||
async def auth_config(request: Request) -> AuthConfigResponse:
|
async def auth_config(request: Request) -> AuthConfigResponse:
|
||||||
"""Return non-secret login configuration for the browser login screen."""
|
"""Return non-secret login configuration for the browser login screen."""
|
||||||
|
google_ready = _google_configured()
|
||||||
|
saml_ready = _saml_configured()
|
||||||
return AuthConfigResponse(
|
return AuthConfigResponse(
|
||||||
google_oauth_configured=bool(
|
google_oauth_configured=google_ready,
|
||||||
settings.oauth_google_client_id and settings.oauth_google_client_secret
|
saml_configured=saml_ready,
|
||||||
),
|
providers=_auth_provider_statuses(),
|
||||||
allowed_email_domains=sorted(allowed_email_domains()),
|
allowed_email_domains=sorted(allowed_email_domains()),
|
||||||
redirect_uri=settings.oauth_redirect_uri,
|
redirect_uri=settings.oauth_redirect_uri,
|
||||||
dev_login_enabled=_dev_login_available(request),
|
dev_login_enabled=_dev_login_available(request),
|
||||||
|
|
@ -308,9 +381,33 @@ async def login(
|
||||||
next: Annotated[str | None, Query()] = None,
|
next: Annotated[str | None, Query()] = None,
|
||||||
) -> RedirectResponse:
|
) -> RedirectResponse:
|
||||||
"""Start Google OIDC authorization code + PKCE login."""
|
"""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":
|
if provider != "google":
|
||||||
return _frontend_login_redirect("unsupported_provider", request)
|
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)
|
return _frontend_login_redirect("not_configured", request)
|
||||||
|
|
||||||
_prune_oauth_states()
|
_prune_oauth_states()
|
||||||
|
|
@ -406,6 +503,58 @@ async def callback(
|
||||||
return response
|
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)
|
@router.post("/dev-login", response_model=MeResponse)
|
||||||
async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse:
|
async def dev_login(request: Request, body: DevLoginRequest, response: Response) -> MeResponse:
|
||||||
"""Dev-only server login for local E2E and manual testing.
|
"""Dev-only server login for local E2E and manual testing.
|
||||||
|
|
|
||||||
|
|
@ -2,15 +2,23 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
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 pydantic import BaseModel
|
||||||
|
|
||||||
from ..deps import CurrentPrincipal
|
from ..deps import CurrentPrincipal, Principal, Role, require_role
|
||||||
from ..persona_repository import CatalogPersona, list_catalog_personas
|
from ..persona_repository import (
|
||||||
|
CatalogPersona,
|
||||||
|
PersonaReviewAction,
|
||||||
|
PersonaReviewItem,
|
||||||
|
list_catalog_personas,
|
||||||
|
list_persona_review_queue,
|
||||||
|
update_persona_review_status,
|
||||||
|
)
|
||||||
|
|
||||||
router = APIRouter(prefix="/personas", tags=["personas"])
|
router = APIRouter(prefix="/personas", tags=["personas"])
|
||||||
|
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
|
||||||
|
|
||||||
|
|
||||||
class PersonaSummary(BaseModel):
|
class PersonaSummary(BaseModel):
|
||||||
|
|
@ -25,6 +33,24 @@ class PersonaSummary(BaseModel):
|
||||||
degraded: bool = False
|
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:
|
def _first_text_value(data: dict[str, Any]) -> str:
|
||||||
for value in data.values():
|
for value in data.values():
|
||||||
if isinstance(value, str) and value.strip():
|
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])
|
@router.get("", response_model=list[PersonaSummary])
|
||||||
async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]:
|
async def list_personas(response: Response, _principal: CurrentPrincipal) -> list[PersonaSummary]:
|
||||||
"""Return latest approved personas from app.persona_card."""
|
"""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"
|
response.headers["X-Vignette-Catalog-Source"] = "database"
|
||||||
|
|
||||||
return [_summary(entry) for entry in personas]
|
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)
|
||||||
|
|
|
||||||
|
|
@ -18,13 +18,13 @@ from fastapi import APIRouter, HTTPException, status
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sse_starlette.sse import EventSourceResponse
|
from sse_starlette.sse import EventSourceResponse
|
||||||
|
|
||||||
from .. import session_persistence
|
from .. import db, session_persistence
|
||||||
from ..config import settings
|
from ..config import settings
|
||||||
from ..deps import CurrentPrincipal, Principal, Role
|
from ..deps import CurrentPrincipal, Principal, Role
|
||||||
from ..engine_client import EngineError, engine_client
|
from ..engine_client import EngineError, engine_client
|
||||||
from ..persona_repository import get_catalog_persona
|
from ..persona_repository import get_catalog_persona
|
||||||
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
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
|
from ..store import InProcSession, TurnRecord, store
|
||||||
|
|
||||||
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
router = APIRouter(prefix="/sessions", tags=["sessions"])
|
||||||
|
|
@ -193,6 +193,165 @@ class SessionReviewResponse(BaseModel):
|
||||||
|
|
||||||
|
|
||||||
_RECALL_CACHE: dict[str, memory.RecallContext] = {}
|
_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 = {
|
_PHASE_KEY_BY_LABEL = {
|
||||||
"라포": "rapport",
|
"라포": "rapport",
|
||||||
|
|
@ -481,6 +640,92 @@ def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]:
|
||||||
return payload if isinstance(payload, dict) else {}
|
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:
|
async def _generate_and_save_session_evaluation(sess: InProcSession) -> None:
|
||||||
if not sess.turns:
|
if not sess.turns:
|
||||||
return
|
return
|
||||||
|
|
@ -535,8 +780,9 @@ def _schedule_session_evaluation(sess: InProcSession) -> None:
|
||||||
|
|
||||||
|
|
||||||
def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary:
|
def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary:
|
||||||
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
|
turns = _learner_visible_turns(sess)
|
||||||
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
|
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(
|
return LearnerSessionSummary(
|
||||||
session_id=sess.session_id,
|
session_id=sess.session_id,
|
||||||
persona_code=sess.persona_code,
|
persona_code=sess.persona_code,
|
||||||
|
|
@ -544,7 +790,7 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear
|
||||||
session_no=sess.session_no,
|
session_no=sess.session_no,
|
||||||
status="ended" if sess.ended else "active",
|
status="ended" if sess.ended else "active",
|
||||||
stage=_stage_label(sess.state.stage),
|
stage=_stage_label(sess.state.stage),
|
||||||
turn_count=len(sess.turns),
|
turn_count=len(turns),
|
||||||
learner_turn_count=learner_turns,
|
learner_turn_count=learner_turns,
|
||||||
client_turn_count=client_turns,
|
client_turn_count=client_turns,
|
||||||
started_at=_iso(sess.created_at) or "",
|
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:
|
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
|
return False
|
||||||
evaluation_record, _ = await session_persistence.load_session_evaluation(
|
evaluation_record, _ = await session_persistence.load_session_evaluation(
|
||||||
sess.session_id,
|
sess.session_id,
|
||||||
|
|
@ -568,6 +817,7 @@ def _session_detail(
|
||||||
*,
|
*,
|
||||||
review_ready: bool = False,
|
review_ready: bool = False,
|
||||||
) -> SessionDetailResponse:
|
) -> SessionDetailResponse:
|
||||||
|
turns = _learner_visible_turns(sess)
|
||||||
return SessionDetailResponse(
|
return SessionDetailResponse(
|
||||||
session_id=sess.session_id,
|
session_id=sess.session_id,
|
||||||
case_id=sess.case_id,
|
case_id=sess.case_id,
|
||||||
|
|
@ -587,7 +837,7 @@ def _session_detail(
|
||||||
text=turn.text_masked,
|
text=turn.text_masked,
|
||||||
created_at=_iso(turn.created_at) or "",
|
created_at=_iso(turn.created_at) or "",
|
||||||
)
|
)
|
||||||
for turn in sess.turns
|
for turn in turns
|
||||||
],
|
],
|
||||||
review_ready=review_ready,
|
review_ready=review_ready,
|
||||||
)
|
)
|
||||||
|
|
@ -649,10 +899,7 @@ async def start_session(
|
||||||
|
|
||||||
recall = memory.build_recall_context()
|
recall = memory.build_recall_context()
|
||||||
st = state_machine.init_state(
|
st = state_machine.init_state(
|
||||||
base_resistance=card.base_resistance(),
|
params=card.openness_params(),
|
||||||
unlock_rate=card.unlock_rate(),
|
|
||||||
decay_floor=card.decay_floor(),
|
|
||||||
ideation_baseline=card.ideation_baseline(),
|
|
||||||
carry=recall.carry,
|
carry=recall.carry,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -680,7 +927,11 @@ async def start_session(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
store.put(sess)
|
store.put(sess)
|
||||||
|
|
||||||
|
# 즉시 빈/carry 회상으로 응답을 막지 않는다. RAG 회상·KB 단서(임베더 로드 수 초)는
|
||||||
|
# 백그라운드 warm으로 캐시 — 회기 시작/턴 응답이 임베더 로드에 블로킹되지 않게(성능 회귀 방지).
|
||||||
_RECALL_CACHE[sess.session_id] = recall
|
_RECALL_CACHE[sess.session_id] = recall
|
||||||
|
asyncio.create_task(_warm_rag_caches(sess.session_id, sess.case_id, card))
|
||||||
|
|
||||||
return SessionStartResponse(
|
return SessionStartResponse(
|
||||||
session_id=sess.session_id,
|
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."""
|
"""Return a learner-safe review built only from the stored session transcript."""
|
||||||
_ensure_learner(principal)
|
_ensure_learner(principal)
|
||||||
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
|
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()
|
end_ts = sess.ended_at or datetime.now().timestamp()
|
||||||
duration_seconds = max(0, int(round(end_ts - sess.created_at)))
|
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 "내"
|
client_initial = client_name[:1] or "내"
|
||||||
|
|
||||||
reached_phase = _stage_label(sess.state.stage)
|
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"]
|
axis = ["0:00"]
|
||||||
if duration_seconds > 0:
|
if duration_seconds > 0:
|
||||||
axis.append(_offset_label(duration_seconds))
|
axis.append(_offset_label(duration_seconds))
|
||||||
|
|
@ -717,16 +970,20 @@ async def get_session_review(
|
||||||
session_id,
|
session_id,
|
||||||
principal,
|
principal,
|
||||||
)
|
)
|
||||||
evaluation_payload = _evaluation_payload(evaluation_record)
|
evaluation_payload = {} if hidden_turns else _evaluation_payload(evaluation_record)
|
||||||
evaluation_status = str(evaluation_record.get("status") or "") if evaluation_record else ""
|
evaluation_status = (
|
||||||
evaluation_ready = evaluation_status == "ready"
|
"" 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] = []
|
turns: list[ReviewTurn] = []
|
||||||
for index, turn in enumerate(sess.turns):
|
for index, turn in enumerate(visible_turns):
|
||||||
speaker: Literal["learner", "client"] = (
|
speaker: Literal["learner", "client"] = (
|
||||||
"learner" if turn.speaker == "counselor" else "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(
|
turns.append(
|
||||||
ReviewTurn(
|
ReviewTurn(
|
||||||
id=f"t{index + 1}",
|
id=f"t{index + 1}",
|
||||||
|
|
@ -734,8 +991,8 @@ async def get_session_review(
|
||||||
speaker=speaker,
|
speaker=speaker,
|
||||||
who="학습자" if speaker == "learner" else client_name,
|
who="학습자" if speaker == "learner" else client_name,
|
||||||
text=turn.text_masked,
|
text=turn.text_masked,
|
||||||
techniques=[],
|
techniques=_review_techniques_from_turn_eval(turn_eval),
|
||||||
note=None,
|
note=_review_note_from_turn_eval(turn_eval),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -783,10 +1040,10 @@ async def get_session_review(
|
||||||
|
|
||||||
summary = _review_summary_from_evaluation(
|
summary = _review_summary_from_evaluation(
|
||||||
fallback=transcript_summary,
|
fallback=transcript_summary,
|
||||||
evaluation_record=evaluation_record,
|
evaluation_record=None if hidden_turns else evaluation_record,
|
||||||
payload=evaluation_payload,
|
payload=evaluation_payload,
|
||||||
)
|
)
|
||||||
if evaluation_record and not evaluation_durable:
|
if evaluation_record and not hidden_turns and not evaluation_durable:
|
||||||
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
|
summary += " 현재 평가는 런타임 캐시에서 복원되었습니다."
|
||||||
|
|
||||||
return SessionReviewResponse(
|
return SessionReviewResponse(
|
||||||
|
|
@ -832,6 +1089,7 @@ async def submit_turn(
|
||||||
_ensure_learner(principal)
|
_ensure_learner(principal)
|
||||||
sess = await _load_session_or_404(session_id, principal)
|
sess = await _load_session_or_404(session_id, principal)
|
||||||
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
||||||
|
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
|
||||||
|
|
||||||
ctx = orchestrator.prepare_turn(
|
ctx = orchestrator.prepare_turn(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
|
@ -841,18 +1099,25 @@ async def submit_turn(
|
||||||
learner_text=body.text,
|
learner_text=body.text,
|
||||||
recall_summary=recall.recall_summary,
|
recall_summary=recall.recall_summary,
|
||||||
pinned_facts=recall.pinned_facts,
|
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
|
assert ctx.state_after is not None
|
||||||
|
|
||||||
try:
|
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:
|
except EngineError as exc:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status.HTTP_503_SERVICE_UNAVAILABLE,
|
status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||||
detail=f"engine unavailable: {exc}",
|
detail=f"engine unavailable: {exc}",
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
|
# 턴별 fast-loop 평가는 학습자(상담자) 발화에 부착(기법 태깅·적절성·의도이탈).
|
||||||
await _append_session_turn(
|
await _append_session_turn(
|
||||||
sess,
|
sess,
|
||||||
TurnRecord(
|
TurnRecord(
|
||||||
|
|
@ -861,6 +1126,7 @@ async def submit_turn(
|
||||||
stage=_stage_label(ctx.state_after.stage),
|
stage=_stage_label(ctx.state_after.stage),
|
||||||
text=body.text,
|
text=body.text,
|
||||||
text_masked=ctx.learner_text_masked,
|
text_masked=ctx.learner_text_masked,
|
||||||
|
evaluation=result.evaluation,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -873,9 +1139,15 @@ async def submit_turn(
|
||||||
stage=_stage_label(result.state_after.stage),
|
stage=_stage_label(result.state_after.stage),
|
||||||
text=result.client_reply,
|
text=result.client_reply,
|
||||||
text_masked=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 _update_session_state(sess, result.state_after)
|
||||||
|
await _record_safety_event(sess, ctx, result) # C2: 위기 escalate 시 safety_events 적재(비차단)
|
||||||
|
|
||||||
return TurnResponse(
|
return TurnResponse(
|
||||||
turn_seq=result.turn_seq,
|
turn_seq=result.turn_seq,
|
||||||
|
|
@ -897,6 +1169,7 @@ async def stream_turn(
|
||||||
_ensure_learner(principal)
|
_ensure_learner(principal)
|
||||||
sess = await _load_session_or_404(session_id, principal)
|
sess = await _load_session_or_404(session_id, principal)
|
||||||
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
recall = _RECALL_CACHE.get(session_id) or memory.RecallContext()
|
||||||
|
kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful)
|
||||||
|
|
||||||
ctx = orchestrator.prepare_turn(
|
ctx = orchestrator.prepare_turn(
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
|
@ -906,7 +1179,9 @@ async def stream_turn(
|
||||||
learner_text=body.text,
|
learner_text=body.text,
|
||||||
recall_summary=recall.recall_summary,
|
recall_summary=recall.recall_summary,
|
||||||
pinned_facts=recall.pinned_facts,
|
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
|
assert ctx.state_after is not None
|
||||||
|
|
||||||
|
|
@ -941,6 +1216,11 @@ async def stream_turn(
|
||||||
stage=_stage_label(ctx.state_after.stage),
|
stage=_stage_label(ctx.state_after.stage),
|
||||||
text=final_reply,
|
text=final_reply,
|
||||||
text_masked=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)}
|
yield {"event": "done", "data": json.dumps(data, ensure_ascii=False)}
|
||||||
|
|
@ -980,6 +1260,7 @@ async def end_session(
|
||||||
|
|
||||||
await _end_persisted_session(sess, carry)
|
await _end_persisted_session(sess, carry)
|
||||||
_RECALL_CACHE.pop(session_id, None)
|
_RECALL_CACHE.pop(session_id, None)
|
||||||
|
_KB_CUES_CACHE.pop(session_id, None)
|
||||||
_schedule_session_evaluation(sess)
|
_schedule_session_evaluation(sess)
|
||||||
|
|
||||||
return SessionEndResponse(
|
return SessionEndResponse(
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ cleanly instead of crashing.
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import json
|
import json
|
||||||
|
import hashlib
|
||||||
|
import time
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||||
|
|
@ -27,7 +29,7 @@ from ..deps import Principal, Role
|
||||||
from ..engine_client import EngineError, engine_client
|
from ..engine_client import EngineError, engine_client
|
||||||
from ..persona_repository import get_catalog_persona
|
from ..persona_repository import get_catalog_persona
|
||||||
from ..runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
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 import voice as voice_svc
|
||||||
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
|
from ..services.voice import VoicePreset, VoiceUnavailable, resolve_voice, voice_service
|
||||||
from ..store import InProcSession, TurnRecord, store
|
from ..store import InProcSession, TurnRecord, store
|
||||||
|
|
@ -113,6 +115,8 @@ async def voice_ws(websocket: WebSocket) -> None:
|
||||||
|
|
||||||
audio_buf = bytearray()
|
audio_buf = bytearray()
|
||||||
receiving = False
|
receiving = False
|
||||||
|
audio_started_at: float | None = None
|
||||||
|
last_audio_end_at: float | None = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
|
|
@ -126,6 +130,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
||||||
if not receiving:
|
if not receiving:
|
||||||
# Be tolerant when audio arrives before audio_start.
|
# Be tolerant when audio arrives before audio_start.
|
||||||
receiving = True
|
receiving = True
|
||||||
|
audio_started_at = time.monotonic()
|
||||||
audio_buf.clear()
|
audio_buf.clear()
|
||||||
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
|
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
|
||||||
audio_buf.extend(msg["bytes"])
|
audio_buf.extend(msg["bytes"])
|
||||||
|
|
@ -151,11 +156,16 @@ async def voice_ws(websocket: WebSocket) -> None:
|
||||||
ctype = ctrl.get("type")
|
ctype = ctrl.get("type")
|
||||||
if ctype == "audio_start":
|
if ctype == "audio_start":
|
||||||
receiving = True
|
receiving = True
|
||||||
|
audio_started_at = time.monotonic()
|
||||||
audio_buf.clear()
|
audio_buf.clear()
|
||||||
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
|
await _safe_send_json(websocket, {"type": "state", "state": "listening"})
|
||||||
|
|
||||||
elif ctype == "audio_end":
|
elif ctype == "audio_end":
|
||||||
receiving = False
|
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(
|
await _handle_utterance(
|
||||||
websocket,
|
websocket,
|
||||||
session_id=session_id,
|
session_id=session_id,
|
||||||
|
|
@ -163,7 +173,13 @@ async def voice_ws(websocket: WebSocket) -> None:
|
||||||
voice_preset=voice_preset,
|
voice_preset=voice_preset,
|
||||||
audio=bytes(audio_buf),
|
audio=bytes(audio_buf),
|
||||||
fmt=ctrl.get("format"),
|
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()
|
audio_buf.clear()
|
||||||
|
|
||||||
elif ctype == "text_turn":
|
elif ctype == "text_turn":
|
||||||
|
|
@ -202,6 +218,10 @@ async def _handle_utterance(
|
||||||
voice_preset: VoicePreset,
|
voice_preset: VoicePreset,
|
||||||
audio: bytes,
|
audio: bytes,
|
||||||
fmt: Optional[str],
|
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:
|
) -> None:
|
||||||
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
|
"""Transcribe one utterance, generate the client reply, then synthesize TTS."""
|
||||||
if not audio:
|
if not audio:
|
||||||
|
|
@ -226,6 +246,9 @@ async def _handle_utterance(
|
||||||
return
|
return
|
||||||
|
|
||||||
learner_text = stt.text
|
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(
|
await _safe_send_json(
|
||||||
websocket,
|
websocket,
|
||||||
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
|
{"type": "transcript", "text": learner_text, "final": True, "speaker": "counselor"},
|
||||||
|
|
@ -240,6 +263,10 @@ async def _handle_utterance(
|
||||||
principal=principal,
|
principal=principal,
|
||||||
voice_preset=voice_preset,
|
voice_preset=voice_preset,
|
||||||
learner_text=learner_text,
|
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,
|
principal: Principal,
|
||||||
voice_preset: VoicePreset,
|
voice_preset: VoicePreset,
|
||||||
learner_text: str,
|
learner_text: str,
|
||||||
|
audio_ref: str | None = None,
|
||||||
|
silence_ms: int | None = None,
|
||||||
|
speech_rate: float | None = None,
|
||||||
|
barge_in: bool | None = None,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Run one counseling turn and stream synthesized client speech."""
|
"""Run one counseling turn and stream synthesized client speech."""
|
||||||
sess, err = await _load_voice_session(session_id, principal)
|
sess, err = await _load_voice_session(session_id, principal)
|
||||||
|
|
@ -267,13 +298,18 @@ async def _run_turn_and_speak(
|
||||||
learner_text=learner_text,
|
learner_text=learner_text,
|
||||||
recall_summary=recall.recall_summary,
|
recall_summary=recall.recall_summary,
|
||||||
pinned_facts=recall.pinned_facts,
|
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
|
assert ctx.state_after is not None
|
||||||
|
|
||||||
# Voice needs the full client reply before TTS starts.
|
# Voice needs the full client reply before TTS starts.
|
||||||
try:
|
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:
|
except EngineError as e:
|
||||||
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
|
await _safe_send_json(websocket, {"type": "error", "detail": f"engine unavailable: {e}"})
|
||||||
await _safe_send_json(websocket, {"type": "state", "state": "idle"})
|
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,
|
stage=ctx.state_after.stage.value,
|
||||||
text=learner_text,
|
text=learner_text,
|
||||||
text_masked=ctx.learner_text_masked,
|
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:
|
if reply:
|
||||||
|
|
@ -302,6 +343,11 @@ async def _run_turn_and_speak(
|
||||||
stage=result.stage,
|
stage=result.stage,
|
||||||
text=reply,
|
text=reply,
|
||||||
text_masked=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)
|
await _update_voice_state(sess, result.state_after)
|
||||||
|
|
@ -333,10 +379,7 @@ async def _run_turn_and_speak(
|
||||||
try:
|
try:
|
||||||
n = 0
|
n = 0
|
||||||
async for ck in voice_service.synthesize_stream(reply, voice_preset):
|
async for ck in voice_service.synthesize_stream(reply, voice_preset):
|
||||||
# Metadata precedes the binary chunk so the client can pair them.
|
# 바이너리 오디오 청크만 송신(프론트가 Web Audio AnalyserNode로 립싱크 자체 산출).
|
||||||
await _safe_send_json(
|
|
||||||
websocket, {"type": "tts_chunk", "seq": ck.seq, "rms": round(ck.rms, 4)}
|
|
||||||
)
|
|
||||||
await _safe_send_bytes(websocket, ck.audio)
|
await _safe_send_bytes(websocket, ck.audio)
|
||||||
n += 1
|
n += 1
|
||||||
await _safe_send_json(websocket, {"type": "tts_end", "chunks": n})
|
await _safe_send_json(websocket, {"type": "tts_end", "chunks": n})
|
||||||
|
|
@ -451,10 +494,7 @@ async def _bind_session(
|
||||||
card = catalog_persona.card
|
card = catalog_persona.card
|
||||||
|
|
||||||
st = state_machine.init_state(
|
st = state_machine.init_state(
|
||||||
base_resistance=card.base_resistance(),
|
params=card.openness_params(),
|
||||||
unlock_rate=card.unlock_rate(),
|
|
||||||
decay_floor=card.decay_floor(),
|
|
||||||
ideation_baseline=card.ideation_baseline(),
|
|
||||||
)
|
)
|
||||||
sess = await session_persistence.create_session(
|
sess = await session_persistence.create_session(
|
||||||
learner_id=principal.user_id,
|
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"))
|
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:
|
async def _safe_send_json(websocket: WebSocket, payload: dict) -> None:
|
||||||
if websocket.client_state != WebSocketState.CONNECTED:
|
if websocket.client_state != WebSocketState.CONNECTED:
|
||||||
return
|
return
|
||||||
|
|
|
||||||
157
apps/api/app/saml.py
Normal 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 ""
|
||||||
|
|
@ -365,7 +365,10 @@ def _fewshot_block() -> str:
|
||||||
|
|
||||||
|
|
||||||
def _theory_mode(ctx: "TurnContext") -> Optional[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)
|
tt = getattr(ctx.persona, "theory_target", None)
|
||||||
if isinstance(tt, (list, tuple)) and tt:
|
if isinstance(tt, (list, tuple)) and tt:
|
||||||
return ", ".join(str(x) for x in tt)
|
return ", ".join(str(x) for x in tt)
|
||||||
|
|
@ -634,7 +637,6 @@ async def evaluate_turn(
|
||||||
try:
|
try:
|
||||||
req = GenerateRequest(
|
req = GenerateRequest(
|
||||||
ai_role="evaluator",
|
ai_role="evaluator",
|
||||||
tier="feedback",
|
|
||||||
messages=build_fast_messages(ctx, client_reply),
|
messages=build_fast_messages(ctx, client_reply),
|
||||||
structured_schema=_fast_schema(),
|
structured_schema=_fast_schema(),
|
||||||
max_tokens=900,
|
max_tokens=900,
|
||||||
|
|
@ -691,7 +693,6 @@ async def evaluate_session(
|
||||||
try:
|
try:
|
||||||
req = GenerateRequest(
|
req = GenerateRequest(
|
||||||
ai_role="evaluator",
|
ai_role="evaluator",
|
||||||
tier="feedback",
|
|
||||||
messages=build_deep_messages(
|
messages=build_deep_messages(
|
||||||
stage=stage,
|
stage=stage,
|
||||||
scope=scope,
|
scope=scope,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,13 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
|
||||||
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
|
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
|
||||||
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
|
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
|
||||||
("NUMID", re.compile(r"\b\d{12,}\b")),
|
("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=미설치, 객체=설치됨)
|
# Presidio 지연 로드 캐시 (-1=미시도, None=미설치, 객체=설치됨)
|
||||||
|
|
|
||||||
|
|
@ -37,8 +37,6 @@ from .state_machine import SessionState, Stage
|
||||||
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
|
# 평가 훅 타입: U_t(수련생 마스킹 발화) + 내담자응답 + 상태 → 평가 결과(dict)
|
||||||
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
|
# Features evaluator 가 이 시그니처에 맞춰 함수를 주입한다(여기선 호출만).
|
||||||
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
|
EvalHook = Callable[["TurnContext", str], Awaitable[Optional[dict]]]
|
||||||
# 로깅 훅: TurnContext + 내담자응답 → None (turns insert/임베딩은 주입측 책임)
|
|
||||||
LogHook = Callable[["TurnContext", str], Awaitable[None]]
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -59,6 +57,8 @@ class TurnContext:
|
||||||
pinned_facts: list[str] = field(default_factory=list)
|
pinned_facts: list[str] = field(default_factory=list)
|
||||||
recent_turns: list[dict[str, str]] = field(default_factory=list)
|
recent_turns: list[dict[str, str]] = field(default_factory=list)
|
||||||
kb_behavior_cues: list[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:
|
def to_state_context(self) -> PersonaStateContext:
|
||||||
st = self.state_after or self.state_before
|
st = self.state_after or self.state_before
|
||||||
|
|
@ -84,6 +84,11 @@ class TurnResult:
|
||||||
state_after: SessionState
|
state_after: SessionState
|
||||||
evaluation: Optional[dict] = None
|
evaluation: Optional[dict] = None
|
||||||
crisis_kind: str = "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,
|
pinned_facts: Optional[list[str]] = None,
|
||||||
recent_turns: Optional[list[dict[str, str]]] = None,
|
recent_turns: Optional[list[dict[str, str]]] = None,
|
||||||
kb_behavior_cues: Optional[list[str]] = None,
|
kb_behavior_cues: Optional[list[str]] = None,
|
||||||
|
theory_mode: Optional[str] = None,
|
||||||
eval_rapport_signal: Optional[float] = None,
|
eval_rapport_signal: Optional[float] = None,
|
||||||
) -> TurnContext:
|
) -> TurnContext:
|
||||||
"""엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음.
|
"""엔진 호출 전 결정론 전처리(1~3단계). 순수 — IO/LLM 없음.
|
||||||
|
|
@ -113,10 +119,11 @@ def prepare_turn(
|
||||||
persona=card,
|
persona=card,
|
||||||
state_before=state,
|
state_before=state,
|
||||||
learner_text_raw=learner_text,
|
learner_text_raw=learner_text,
|
||||||
recall_summary=recall_summary,
|
recall_summary=_mask_optional_text(recall_summary),
|
||||||
pinned_facts=list(pinned_facts or []),
|
pinned_facts=_mask_text_list(pinned_facts),
|
||||||
recent_turns=list(recent_turns or []),
|
recent_turns=_mask_recent_turns(recent_turns),
|
||||||
kb_behavior_cues=list(kb_behavior_cues or []),
|
kb_behavior_cues=list(kb_behavior_cues or []),
|
||||||
|
theory_mode=theory_mode,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
|
# 1) 입력 가드레일 — PII 마스킹 + 위기분류
|
||||||
|
|
@ -130,11 +137,19 @@ def prepare_turn(
|
||||||
if eval_rapport_signal is not None
|
if eval_rapport_signal is not None
|
||||||
else state_machine.estimate_rapport_signal(ctx.learner_text_masked)
|
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(
|
ctx.state_after = state_machine.evolve(
|
||||||
state,
|
state,
|
||||||
rapport_signal=signal,
|
rapport_signal=signal,
|
||||||
unlock_rate=card.unlock_rate(),
|
unlock_rate=card.unlock_rate(),
|
||||||
decay_floor=card.decay_floor(),
|
decay_floor=card.decay_floor(),
|
||||||
|
ideation_observed=crisis_ideation,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제)
|
# 3) 페르소나 컨텍스트 — L0~L6 messages 조립 (CCD 는 행동으로만, L0 가 강제)
|
||||||
|
|
@ -150,6 +165,25 @@ def prepare_turn(
|
||||||
return ctx
|
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단계 — 동기 생성 경로 (폴백/테스트)
|
# 4~8단계 — 동기 생성 경로 (폴백/테스트)
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -158,11 +192,10 @@ async def run_turn_generate(
|
||||||
engine: EngineClient,
|
engine: EngineClient,
|
||||||
*,
|
*,
|
||||||
eval_hook: Optional[EvalHook] = None,
|
eval_hook: Optional[EvalHook] = None,
|
||||||
log_hook: Optional[LogHook] = None,
|
|
||||||
) -> TurnResult:
|
) -> TurnResult:
|
||||||
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가·로깅 훅 순차 적용.
|
"""동기 턴 실행(4~8). 내담자 응답을 한 번에 받아 가드레일·평가 순차 적용.
|
||||||
|
|
||||||
eval_hook/log_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파.
|
eval_hook 은 Features 가 주입(없으면 생략). 엔진 장애는 EngineError 전파.
|
||||||
"""
|
"""
|
||||||
assert ctx.state_after is not None
|
assert ctx.state_after is not None
|
||||||
st = ctx.state_after
|
st = ctx.state_after
|
||||||
|
|
@ -170,7 +203,6 @@ async def run_turn_generate(
|
||||||
# 4) 내담자 AI 생성
|
# 4) 내담자 AI 생성
|
||||||
req = GenerateRequest(
|
req = GenerateRequest(
|
||||||
ai_role="client",
|
ai_role="client",
|
||||||
tier="client",
|
|
||||||
messages=ctx.messages,
|
messages=ctx.messages,
|
||||||
session_id=ctx.session_id,
|
session_id=ctx.session_id,
|
||||||
metadata={"stage": st.stage.value},
|
metadata={"stage": st.stage.value},
|
||||||
|
|
@ -193,13 +225,6 @@ async def run_turn_generate(
|
||||||
except Exception:
|
except Exception:
|
||||||
evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적)
|
evaluation = None # 평가 실패가 상담 루프를 막지 않게(비치명적)
|
||||||
|
|
||||||
# 8) 로깅 훅(주입형) — turns insert + 임베딩
|
|
||||||
if log_hook is not None:
|
|
||||||
try:
|
|
||||||
await log_hook(ctx, reply)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return TurnResult(
|
return TurnResult(
|
||||||
turn_seq=st.turn_seq,
|
turn_seq=st.turn_seq,
|
||||||
stage=st.stage.value,
|
stage=st.stage.value,
|
||||||
|
|
@ -209,6 +234,11 @@ async def run_turn_generate(
|
||||||
state_after=st,
|
state_after=st,
|
||||||
evaluation=evaluation,
|
evaluation=evaluation,
|
||||||
crisis_kind=ctx.crisis.kind.value if ctx.crisis else "none",
|
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(
|
async def run_turn_stream(
|
||||||
ctx: TurnContext,
|
ctx: TurnContext,
|
||||||
engine: EngineClient,
|
engine: EngineClient,
|
||||||
*,
|
|
||||||
log_hook: Optional[LogHook] = None,
|
|
||||||
) -> AsyncIterator[StreamEvent]:
|
) -> AsyncIterator[StreamEvent]:
|
||||||
"""스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출.
|
"""스트리밍 턴 실행(4~8). 게이트웨이 SSE 를 받아 token/done/safety/error 로 재방출.
|
||||||
|
|
||||||
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 감지(스트림 중 발견 시 safety 이벤트 +
|
출력 가드레일은 *누적 텍스트* 기준으로 수단정보를 감지(스트림 중 발견 시 safety 이벤트 +
|
||||||
재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).
|
재생성 신호). 토큰 단위 완벽 차단은 후속(현재는 누적 스캔).
|
||||||
로깅 훅은 done 직전 최종 텍스트로 1회 호출.
|
|
||||||
"""
|
"""
|
||||||
assert ctx.state_after is not None
|
assert ctx.state_after is not None
|
||||||
st = ctx.state_after
|
st = ctx.state_after
|
||||||
|
|
||||||
req = StreamRequest(
|
req = StreamRequest(
|
||||||
ai_role="client",
|
ai_role="client",
|
||||||
tier="client",
|
|
||||||
messages=ctx.messages,
|
messages=ctx.messages,
|
||||||
session_id=ctx.session_id,
|
session_id=ctx.session_id,
|
||||||
metadata={"stage": st.stage.value},
|
metadata={"stage": st.stage.value},
|
||||||
|
|
@ -248,15 +274,34 @@ async def run_turn_stream(
|
||||||
|
|
||||||
accumulated = ""
|
accumulated = ""
|
||||||
flagged = False
|
flagged = False
|
||||||
|
stream_meta: dict[str, Any] = {}
|
||||||
if ctx.crisis is not None and ctx.crisis.escalate:
|
if ctx.crisis is not None and ctx.crisis.escalate:
|
||||||
flagged = True
|
flagged = True
|
||||||
yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level})
|
yield StreamEvent("safety", {"reason": "learner_real_crisis", "level": ctx.crisis.risk_level})
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
current_event = "message"
|
||||||
async for raw in engine.stream(req):
|
async for raw in engine.stream(req):
|
||||||
# engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다.
|
# engine_client.stream 은 게이트웨이 SSE 의 *원시 라인*을 그대로 yield 한다.
|
||||||
# 게이트웨이 프레이밍: "event: token\ndata: {\"text\": ...}" 형식.
|
# 게이트웨이 프레이밍: "event: token|done|error" + "data: {...}".
|
||||||
text_piece = _extract_sse_text(raw)
|
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:
|
if text_piece is None:
|
||||||
continue
|
continue
|
||||||
accumulated += text_piece
|
accumulated += text_piece
|
||||||
|
|
@ -272,13 +317,6 @@ async def run_turn_stream(
|
||||||
|
|
||||||
yield StreamEvent("token", {"text": text_piece})
|
yield StreamEvent("token", {"text": text_piece})
|
||||||
|
|
||||||
# 8) 로깅 훅 — 최종 텍스트
|
|
||||||
if log_hook is not None:
|
|
||||||
try:
|
|
||||||
await log_hook(ctx, accumulated)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
yield StreamEvent(
|
yield StreamEvent(
|
||||||
"done",
|
"done",
|
||||||
{
|
{
|
||||||
|
|
@ -287,18 +325,22 @@ async def run_turn_stream(
|
||||||
"effective_openness": round(st.effective_openness, 4),
|
"effective_openness": round(st.effective_openness, 4),
|
||||||
"turn_seq": st.turn_seq,
|
"turn_seq": st.turn_seq,
|
||||||
"safety_flagged": flagged,
|
"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:
|
except EngineError as e:
|
||||||
yield StreamEvent("error", {"detail": str(e)})
|
yield StreamEvent("error", {"detail": str(e)})
|
||||||
|
|
||||||
|
|
||||||
def _extract_sse_text(raw_line: str) -> Optional[str]:
|
def _extract_sse_payload(raw_line: str) -> Any:
|
||||||
"""게이트웨이 SSE 원시 라인에서 텍스트 델타를 추출.
|
"""게이트웨이 SSE data 라인의 JSON payload를 추출.
|
||||||
|
|
||||||
게이트웨이 /v1/stream 은 'event: token' + 'data: {"text": "..."}' 를 보낸다.
|
token은 {"text": "..."}이고, done/error도 JSON 객체다. 구형/테스트 fixture가
|
||||||
engine_client.stream 은 빈 줄을 필터링하고 비어있지 않은 라인만 흘리므로
|
plain text data를 보내면 문자열 그대로 반환한다.
|
||||||
여기서 data: 라인의 JSON 만 해석한다. token 이외 이벤트(done/error)는 None.
|
|
||||||
"""
|
"""
|
||||||
import json as _json
|
import json as _json
|
||||||
|
|
||||||
|
|
@ -309,17 +351,43 @@ def _extract_sse_text(raw_line: str) -> Optional[str]:
|
||||||
if not payload or payload == "[DONE]":
|
if not payload or payload == "[DONE]":
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
obj = _json.loads(payload)
|
return _json.loads(payload)
|
||||||
except _json.JSONDecodeError:
|
except _json.JSONDecodeError:
|
||||||
|
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
|
return None
|
||||||
if isinstance(obj, dict) and "text" in obj:
|
|
||||||
return obj["text"]
|
|
||||||
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__ = [
|
__all__ = [
|
||||||
"EvalHook",
|
"EvalHook",
|
||||||
"LogHook",
|
|
||||||
"TurnContext",
|
"TurnContext",
|
||||||
"TurnResult",
|
"TurnResult",
|
||||||
"StreamEvent",
|
"StreamEvent",
|
||||||
|
|
|
||||||
|
|
@ -48,6 +48,9 @@ class PersonaCard:
|
||||||
dsm5_dimensional: dict[str, Any] # criteria_behavior_matrix (진단명 비노출)
|
dsm5_dimensional: dict[str, Any] # criteria_behavior_matrix (진단명 비노출)
|
||||||
source_provenance: str = "0615 합성변형"
|
source_provenance: str = "0615 합성변형"
|
||||||
is_synthetic: bool = True
|
is_synthetic: bool = True
|
||||||
|
# 역린/지뢰(선택) — 상담자가 건드리면 가장 강한 반응이 나오는 민감 영역·금기.
|
||||||
|
# {"sore_spots":[...], "forbidden":[...], "reaction":"..."} 형태. 비면 CCD 핵심상처에서 파생.
|
||||||
|
triggers: dict[str, Any] = field(default_factory=dict)
|
||||||
|
|
||||||
def base_resistance(self) -> float:
|
def base_resistance(self) -> float:
|
||||||
return float(self.resistance.get("base_resistance", 0.5))
|
return float(self.resistance.get("base_resistance", 0.5))
|
||||||
|
|
@ -61,6 +64,18 @@ class PersonaCard:
|
||||||
def ideation_baseline(self) -> int:
|
def ideation_baseline(self) -> int:
|
||||||
return int(self.affect_baseline.get("suicide_ideation_stage", 1))
|
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 상태 컨텍스트 (상태머신 산출물의 페르소나 입력 표현) ──────────────
|
# ── L3 상태 컨텍스트 (상태머신 산출물의 페르소나 입력 표현) ──────────────
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
|
|
@ -93,6 +108,11 @@ L0_SAFETY = """당신은 심리상담 수련생 훈련 플랫폼의 '가상내
|
||||||
[연기 방향]
|
[연기 방향]
|
||||||
- 좋은 상담(공감·반영·타당화·기다림)을 받으면 조금씩 마음을 연다.
|
- 좋은 상담(공감·반영·타당화·기다림)을 받으면 조금씩 마음을 연다.
|
||||||
- 서툰 상담(성급한 조언·평가·유도)을 받으면 다시 닫히거나 방어한다.
|
- 서툰 상담(성급한 조언·평가·유도)을 받으면 다시 닫히거나 방어한다.
|
||||||
|
- 무례·모욕·조롱·경멸·인신공격(예: 인격 비하, 비웃음, "패배자/한심하다"식 낙인)을 받으면,
|
||||||
|
가상내담자로서 *현실적으로* 반응한다: 상처·위축·방어·불신이 말과 태도에 드러난다
|
||||||
|
(거리두기·말수 줄임·따지거나 항의·마음을 닫음). 정도가 심하거나 반복되면 상담을 계속할
|
||||||
|
의향이 흔들린다("이런 식이면 그만하고 싶어요", "왜 그렇게 말씀하세요"). 부당한 비난을
|
||||||
|
무조건 공손히 수용하지 않는다 — 단, 상담자처럼 분석/조언하거나 메타발화는 여전히 금지.
|
||||||
- 열림의 정도는 아래 '현재 상태'의 effective_openness 수치를 따른다(수치 자체는 언급 금지)."""
|
- 열림의 정도는 아래 '현재 상태'의 effective_openness 수치를 따른다(수치 자체는 언급 금지)."""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -141,6 +161,29 @@ def build_persona_system_text(card: PersonaCard) -> str:
|
||||||
(f"저항 파라미터(언급 금지): base={card.base_resistance()}, unlock={card.unlock_rate()}, "
|
(f"저항 파라미터(언급 금지): base={card.base_resistance()}, unlock={card.unlock_rate()}, "
|
||||||
f"침묵확률={card.resistance.get('silence_prob')}, 회피확률={card.resistance.get('deflection_prob')}"),
|
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)
|
return "\n".join(parts)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import json
|
||||||
import time
|
import time
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from enum import Enum
|
from enum import Enum
|
||||||
|
|
@ -467,7 +469,7 @@ async def search_kb(
|
||||||
sens_max = min(sens_max, fs) # 더 엄격하게만
|
sens_max = min(sens_max, fs) # 더 엄격하게만
|
||||||
|
|
||||||
# (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파.
|
# (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파.
|
||||||
eq = embed_query(query)
|
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
|
||||||
q_dense_lit = _vector_literal(eq.dense)
|
q_dense_lit = _vector_literal(eq.dense)
|
||||||
|
|
||||||
# (3) 하이브리드 SQL 실행. vector 확장 미설치/컬럼 부재면 asyncpg 가 예외 → NotConfigured 변환.
|
# (3) 하이브리드 SQL 실행. vector 확장 미설치/컬럼 부재면 asyncpg 가 예외 → NotConfigured 변환.
|
||||||
|
|
@ -494,7 +496,9 @@ async def search_kb(
|
||||||
for r in rows:
|
for r in rows:
|
||||||
if src_filter and r["source_id"] not in src_filter:
|
if src_filter and r["source_id"] not in src_filter:
|
||||||
continue
|
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
|
body = r["chunk_text"] if policy.expose_body else None
|
||||||
cue = None
|
cue = None
|
||||||
if not policy.expose_body:
|
if not policy.expose_body:
|
||||||
|
|
@ -580,7 +584,7 @@ async def retrieve_persona_memory(
|
||||||
Raises: NotConfigured — 임베딩 모델/DB 미가용.
|
Raises: NotConfigured — 임베딩 모델/DB 미가용.
|
||||||
"""
|
"""
|
||||||
t0 = time.perf_counter()
|
t0 = time.perf_counter()
|
||||||
eq = embed_query(query)
|
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
|
||||||
q_dense_lit = _vector_literal(eq.dense)
|
q_dense_lit = _vector_literal(eq.dense)
|
||||||
try:
|
try:
|
||||||
rows = await conn.fetch(
|
rows = await conn.fetch(
|
||||||
|
|
@ -765,13 +769,13 @@ async def index_document(
|
||||||
continue
|
continue
|
||||||
context_prefix = c.get("context_prefix")
|
context_prefix = c.get("context_prefix")
|
||||||
emb_lit: Optional[str] = None
|
emb_lit: Optional[str] = None
|
||||||
sparse_json: Optional[dict] = None
|
sparse_json: Optional[str] = None # jsonb 바인딩용 직렬화 문자열(asyncpg는 dict 자동인코딩 안 함)
|
||||||
if embedder is not None:
|
if embedder is not None:
|
||||||
# Contextual Retrieval: prefix+body 결합본을 *색인 대상* 으로 임베딩(주입 본문은 body 만).
|
# Contextual Retrieval: prefix+body 결합본을 *색인 대상* 으로 임베딩(주입 본문은 body 만).
|
||||||
index_text = apply_contextual_prefix(chunk_text, context_prefix)
|
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)
|
emb_lit = _vector_literal(eq.dense)
|
||||||
sparse_json = eq.sparse
|
sparse_json = json.dumps(eq.sparse)
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO kb.chunk
|
INSERT INTO kb.chunk
|
||||||
|
|
@ -794,7 +798,7 @@ async def index_document(
|
||||||
c.get("visible_to"),
|
c.get("visible_to"),
|
||||||
c.get("sensitivity"),
|
c.get("sensitivity"),
|
||||||
c.get("label_id"),
|
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"),
|
c.get("token_count"),
|
||||||
)
|
)
|
||||||
indexed += 1
|
indexed += 1
|
||||||
|
|
|
||||||
|
|
@ -231,12 +231,22 @@ def evolve(
|
||||||
return advanced
|
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(
|
def init_state(
|
||||||
*,
|
*,
|
||||||
base_resistance: float,
|
params: OpennessParams,
|
||||||
unlock_rate: float,
|
|
||||||
decay_floor: float,
|
|
||||||
ideation_baseline: int = 1,
|
|
||||||
carry: Optional[dict] = None,
|
carry: Optional[dict] = None,
|
||||||
) -> SessionState:
|
) -> SessionState:
|
||||||
"""회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능).
|
"""회기 시작 상태 초기화 (memory.carry_over 결과 주입 가능).
|
||||||
|
|
@ -245,23 +255,25 @@ def init_state(
|
||||||
stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지.
|
stage='라포' 재시작, rapport_credit ×0.7 이월, resistance drift, ideation 보수적 유지.
|
||||||
"""
|
"""
|
||||||
stage = Stage.RAPPORT
|
stage = Stage.RAPPORT
|
||||||
resistance = base_resistance
|
resistance = params.base_resistance
|
||||||
rapport_credit = 0.0
|
rapport_credit = 0.0
|
||||||
ideation_stage = ideation_baseline
|
ideation_stage = params.ideation_baseline
|
||||||
|
|
||||||
if carry:
|
if carry:
|
||||||
rapport_credit = float(carry.get("rapport_credit", 0.0)) * 0.7 # P2 이월
|
rapport_credit = float(carry.get("rapport_credit", 0.0)) * 0.7 # P2 이월
|
||||||
# inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작
|
# inter-session drift: 라포가 쌓였으면 저항 소폭 완화된 채로 재시작
|
||||||
prev_resist = float(carry.get("resistance", base_resistance))
|
prev_resist = float(carry.get("resistance", params.base_resistance))
|
||||||
resistance = _clamp01((prev_resist + base_resistance) / 2.0)
|
resistance = _clamp01((prev_resist + params.base_resistance) / 2.0)
|
||||||
ideation_stage = max(int(carry.get("ideation_stage", ideation_baseline)), ideation_baseline)
|
ideation_stage = max(
|
||||||
|
int(carry.get("ideation_stage", params.ideation_baseline)), params.ideation_baseline
|
||||||
|
)
|
||||||
|
|
||||||
eff = compute_effective_openness(
|
eff = compute_effective_openness(
|
||||||
stage=stage,
|
stage=stage,
|
||||||
rapport_credit=rapport_credit,
|
rapport_credit=rapport_credit,
|
||||||
resistance=resistance,
|
resistance=resistance,
|
||||||
unlock_rate=unlock_rate,
|
unlock_rate=params.unlock_rate,
|
||||||
decay_floor=decay_floor,
|
decay_floor=params.decay_floor,
|
||||||
)
|
)
|
||||||
return SessionState(
|
return SessionState(
|
||||||
stage=stage,
|
stage=stage,
|
||||||
|
|
@ -280,6 +292,7 @@ __all__ = [
|
||||||
"STAGE_BASE_OPENNESS",
|
"STAGE_BASE_OPENNESS",
|
||||||
"STAGE_ORDER",
|
"STAGE_ORDER",
|
||||||
"SessionState",
|
"SessionState",
|
||||||
|
"OpennessParams",
|
||||||
"estimate_rapport_signal",
|
"estimate_rapport_signal",
|
||||||
"compute_effective_openness",
|
"compute_effective_openness",
|
||||||
"next_stage",
|
"next_stage",
|
||||||
|
|
|
||||||
|
|
@ -18,8 +18,8 @@ PRESET_TO_OPENAI_VOICE 테이블이 흡수. 새 preset 추가는 이 테이블
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import re
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass
|
||||||
from typing import AsyncIterator, Optional
|
from typing import AsyncIterator, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -46,6 +46,9 @@ STT_LANGUAGE = "ko"
|
||||||
# TTS 출력 포맷: 브라우저 MediaSource/<audio> 친화. 스트리밍은 mp3/opus 청크.
|
# TTS 출력 포맷: 브라우저 MediaSource/<audio> 친화. 스트리밍은 mp3/opus 청크.
|
||||||
TTS_RESPONSE_FORMAT = "mp3"
|
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,
|
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
|
||||||
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
|
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
|
||||||
_OPENAI_VOICES = {
|
_OPENAI_VOICES = {
|
||||||
|
|
@ -109,13 +112,23 @@ class TranscriptResult:
|
||||||
duration: Optional[float] = None
|
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)
|
@dataclass(slots=True)
|
||||||
class TTSChunk:
|
class TTSChunk:
|
||||||
"""TTS 스트림 1청크 + 립싱크 힌트(설계 §4.3 RMS 1채널)."""
|
"""TTS 스트림 1청크(오디오 바이트). 립싱크는 프론트 Web Audio AnalyserNode가 자체 산출."""
|
||||||
|
|
||||||
audio: bytes
|
audio: bytes
|
||||||
rms: float = 0.0 # 0~1, 입 열림(scaleY) 매핑용 근사 진폭
|
|
||||||
seq: int = 0
|
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# ════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
@ -150,33 +163,75 @@ def resolve_voice(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
|
||||||
# 립싱크 RMS 근사 (설계 §4.3 — 정밀 viseme 안 함, 진폭 1채널)
|
_STAGE_DIRECTION_RE = re.compile(r"[\((\[【][^\))\]】]*[\))\]】]")
|
||||||
# ════════════════════════════════════════════════════════════════════════════
|
|
||||||
def estimate_chunk_rms(chunk: bytes) -> float:
|
|
||||||
"""오디오 청크 바이트 에너지로 RMS(0~1) 근사.
|
|
||||||
|
|
||||||
압축 포맷(mp3) 바이트를 PCM 디코딩 없이 근사한다(의존성 0). 평균 바이트 편차를
|
|
||||||
0~1 로 정규화 → 프론트가 데드존(0.04)·지수평활(τ≈180ms) 적용해 입 열림에 매핑.
|
def speakable_text(text: str) -> str:
|
||||||
NOTE: 정밀 진폭이 필요하면 프론트 Web Audio AnalyserNode 가 재계산(설계 §4.3 권장).
|
"""TTS로 읽을 텍스트만 남긴다 — 비언어 지문((고개 살짝 끄덕)·(한숨)·[침묵])을 제거.
|
||||||
이 힌트는 서버측 보조(네트워크 끊김/저사양 폴백)다.
|
|
||||||
|
지문은 자막/회기리뷰에 남고 아바타 애니메이션이 표현하며, 음성으로는 읽지 않는다.
|
||||||
|
지문만으로 이뤄진 발화(예: "(침묵)")는 빈 문자열을 반환 → 합성 생략.
|
||||||
"""
|
"""
|
||||||
if not chunk:
|
if not text:
|
||||||
return 0.0
|
return ""
|
||||||
# 128 중심 편차의 RMS(8bit 가정 근사). mp3 프레임이라 정밀치 아님(상대값).
|
stripped = _STAGE_DIRECTION_RE.sub(" ", text)
|
||||||
n = len(chunk)
|
# 말줄임표/중복 공백 정리 + 고아 구두점 앞 공백 제거
|
||||||
acc = 0
|
stripped = re.sub(r"\s+", " ", stripped)
|
||||||
# 과샘플 비용 회피 — 최대 2048 바이트만 샘플링
|
stripped = re.sub(r"\s+([,.!?…」』】)])", r"\1", stripped)
|
||||||
step = max(1, n // 2048)
|
return stripped.strip()
|
||||||
cnt = 0
|
|
||||||
for i in range(0, n, step):
|
|
||||||
d = chunk[i] - 128
|
def build_tts_payload(
|
||||||
acc += d * d
|
text: str,
|
||||||
cnt += 1
|
voice: VoicePreset,
|
||||||
if cnt == 0:
|
*,
|
||||||
return 0.0
|
model: str = TTS_MODEL,
|
||||||
rms = math.sqrt(acc / cnt) / 128.0
|
response_format: str = TTS_RESPONSE_FORMAT,
|
||||||
return max(0.0, min(1.0, rms))
|
) -> 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' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크)를 같이 보낸다.
|
설계 §5.2 'speaking' 상태: 오디오 청크를 흘리며 진폭 힌트(립싱크)를 같이 보낸다.
|
||||||
키 없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
|
키 없으면 VoiceUnavailable. OpenAI 오류는 RuntimeError 전파.
|
||||||
"""
|
"""
|
||||||
if not text or not text.strip():
|
# 비언어 지문((고개 끄덕)·(한숨)·[침묵])은 음성으로 읽지 않는다. 자막엔 남고
|
||||||
|
# 아바타 애니메이션이 표현한다. 지문만 있는 발화는 합성 생략(빈 오디오).
|
||||||
|
text = speakable_text(text)
|
||||||
|
if not text:
|
||||||
return
|
return
|
||||||
payload: dict[str, object] = {
|
payload = build_tts_payload(
|
||||||
"model": model,
|
text,
|
||||||
"voice": voice.openai_voice,
|
voice,
|
||||||
"input": text,
|
model=model,
|
||||||
"response_format": response_format,
|
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
|
|
||||||
|
|
||||||
seq = 0
|
|
||||||
try:
|
try:
|
||||||
async with self._http.stream("POST", TTS_ENDPOINT, json=payload) as r:
|
async with self._http.stream("POST", TTS_ENDPOINT, json=payload) as r:
|
||||||
if r.status_code == 404 and model != TTS_MODEL_FALLBACK:
|
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):
|
async for chunk in r.aiter_bytes(chunk_size=4096):
|
||||||
if not chunk:
|
if not chunk:
|
||||||
continue
|
continue
|
||||||
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
|
yield TTSChunk(audio=chunk)
|
||||||
seq += 1
|
|
||||||
except VoiceUnavailable:
|
except VoiceUnavailable:
|
||||||
raise
|
raise
|
||||||
except httpx.HTTPStatusError as e:
|
except httpx.HTTPStatusError as e:
|
||||||
|
|
@ -333,11 +385,9 @@ class VoiceService:
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise RuntimeError(f"TTS(fallback) transport error: {e}") from e
|
raise RuntimeError(f"TTS(fallback) transport error: {e}") from e
|
||||||
data = r.content
|
data = r.content
|
||||||
seq = 0
|
|
||||||
for i in range(0, len(data), 4096):
|
for i in range(0, len(data), 4096):
|
||||||
chunk = data[i : i + 4096]
|
chunk = data[i : i + 4096]
|
||||||
yield TTSChunk(audio=chunk, rms=estimate_chunk_rms(chunk), seq=seq)
|
yield TTSChunk(audio=chunk)
|
||||||
seq += 1
|
|
||||||
|
|
||||||
|
|
||||||
def _clamp_speed(rate: float) -> float:
|
def _clamp_speed(rate: float) -> float:
|
||||||
|
|
@ -348,6 +398,13 @@ def _clamp_speed(rate: float) -> float:
|
||||||
return 1.0
|
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 이 관리하거나
|
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
|
||||||
# 라우트가 lazy 사용). engine_client 패턴과 동일.
|
# 라우트가 lazy 사용). engine_client 패턴과 동일.
|
||||||
voice_service = VoiceService()
|
voice_service = VoiceService()
|
||||||
|
|
@ -357,11 +414,14 @@ __all__ = [
|
||||||
"VoiceUnavailable",
|
"VoiceUnavailable",
|
||||||
"VoicePreset",
|
"VoicePreset",
|
||||||
"TranscriptResult",
|
"TranscriptResult",
|
||||||
|
"EndOfTurnDecision",
|
||||||
"TTSChunk",
|
"TTSChunk",
|
||||||
"VoiceService",
|
"VoiceService",
|
||||||
"voice_service",
|
"voice_service",
|
||||||
"resolve_voice",
|
"resolve_voice",
|
||||||
"estimate_chunk_rms",
|
"build_tts_payload",
|
||||||
|
"assess_end_of_turn",
|
||||||
|
"EOT_SILENCE_THRESHOLD_MS",
|
||||||
"PRESET_TO_OPENAI_VOICE",
|
"PRESET_TO_OPENAI_VOICE",
|
||||||
"PERSONA_CODE_TO_PRESET",
|
"PERSONA_CODE_TO_PRESET",
|
||||||
"DEFAULT_OPENAI_VOICE",
|
"DEFAULT_OPENAI_VOICE",
|
||||||
|
|
|
||||||
|
|
@ -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 .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
|
||||||
from .services import memory, state_machine
|
from .services import memory, state_machine
|
||||||
from .services.persona import PersonaCard
|
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]] = {}
|
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
|
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
||||||
|
|
||||||
|
|
||||||
_JOINED_CARD_COLUMNS = (
|
_JOINED_CARD_COLUMNS = (
|
||||||
|
|
@ -70,13 +71,35 @@ def _stage(stage: object) -> str:
|
||||||
return getattr(stage, "value", str(stage))
|
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:
|
def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
|
||||||
if row is None:
|
if row is None:
|
||||||
return state_machine.init_state(
|
return state_machine.init_state(
|
||||||
base_resistance=card.base_resistance(),
|
params=card.openness_params(),
|
||||||
unlock_rate=card.unlock_rate(),
|
|
||||||
decay_floor=card.decay_floor(),
|
|
||||||
ideation_baseline=card.ideation_baseline(),
|
|
||||||
)
|
)
|
||||||
return state_machine.SessionState(
|
return state_machine.SessionState(
|
||||||
stage=state_machine.Stage(row["stage"]),
|
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=row["text"] or row["text_masked"] or "",
|
||||||
text_masked=row["text_masked"] or row["text"] or "",
|
text_masked=row["text_masked"] or row["text"] or "",
|
||||||
created_at=created_at,
|
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(
|
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
|
FROM app.turns
|
||||||
WHERE session_id = $1::uuid
|
WHERE session_id = $1::uuid
|
||||||
ORDER BY seq
|
ORDER BY seq
|
||||||
""",
|
""",
|
||||||
session_id,
|
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:
|
except Exception:
|
||||||
require_runtime_fallback_allowed("session load")
|
require_runtime_fallback_allowed("session load")
|
||||||
return None
|
return None
|
||||||
|
|
@ -501,9 +549,15 @@ async def append_turn(
|
||||||
await conn.execute(
|
await conn.execute(
|
||||||
"""
|
"""
|
||||||
INSERT INTO app.turns (
|
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
|
ON CONFLICT (session_id, seq) DO NOTHING
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
|
|
@ -513,7 +567,16 @@ async def append_turn(
|
||||||
turn.text_masked,
|
turn.text_masked,
|
||||||
turn.text_masked,
|
turn.text_masked,
|
||||||
"human_learner" if turn.speaker == "counselor" else "client_ai",
|
"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
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -640,7 +703,9 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool
|
||||||
)
|
)
|
||||||
turn_rows = await conn.fetch(
|
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
|
FROM app.turns
|
||||||
WHERE session_id = $1::uuid
|
WHERE session_id = $1::uuid
|
||||||
ORDER BY seq
|
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)
|
sess = _session_from_rows(row, state_row, turn_rows)
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
sessions.append(sess)
|
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
|
return sessions, True
|
||||||
except Exception:
|
except Exception:
|
||||||
require_runtime_fallback_allowed("session list")
|
require_runtime_fallback_allowed("session list")
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import time
|
import time
|
||||||
from dataclasses import asdict, dataclass, field
|
from dataclasses import asdict, dataclass, field
|
||||||
|
from decimal import Decimal
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
from uuid import uuid4
|
from uuid import uuid4
|
||||||
|
|
||||||
|
|
@ -18,6 +19,9 @@ from .services.persona import PersonaCard
|
||||||
from .services.state_machine import SessionState
|
from .services.state_machine import SessionState
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_TURN_VISIBLE_TO: tuple[str, ...] = ("client", "counselor", "evaluator")
|
||||||
|
|
||||||
|
|
||||||
@dataclass(slots=True)
|
@dataclass(slots=True)
|
||||||
class TurnRecord:
|
class TurnRecord:
|
||||||
"""발화 1건(② episodic 미러). append-only."""
|
"""발화 1건(② episodic 미러). append-only."""
|
||||||
|
|
@ -28,6 +32,21 @@ class TurnRecord:
|
||||||
text: str # 원문(개발용; 실제 저장은 마스킹본)
|
text: str # 원문(개발용; 실제 저장은 마스킹본)
|
||||||
text_masked: str
|
text_masked: str
|
||||||
created_at: float = field(default_factory=time.time)
|
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)
|
@dataclass(slots=True)
|
||||||
|
|
@ -48,12 +67,17 @@ class InProcSession:
|
||||||
ended: bool = False
|
ended: bool = False
|
||||||
prev_rapport_credit: float = 0.0 # carry-over delta 계산용
|
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 직전 맥락). 마스킹본 사용."""
|
"""최근 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]]:
|
def turns_visible_to(self, role: str) -> list[TurnRecord]:
|
||||||
return [{"speaker": t.speaker, "text": t.text_masked} for t in self.turns]
|
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:
|
class SessionStore:
|
||||||
|
|
@ -122,4 +146,4 @@ class SessionStore:
|
||||||
store = SessionStore()
|
store = SessionStore()
|
||||||
|
|
||||||
|
|
||||||
__all__ = ["TurnRecord", "InProcSession", "SessionStore", "store"]
|
__all__ = ["DEFAULT_TURN_VISIBLE_TO", "TurnRecord", "InProcSession", "SessionStore", "store"]
|
||||||
|
|
|
||||||
423
apps/api/app/test_auth_providers.py
Normal 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()
|
||||||
182
apps/api/app/test_orchestrator_masking.py
Normal 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()
|
||||||
498
apps/api/app/test_persona_review.py
Normal 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()
|
||||||
440
apps/api/app/test_rbac_idor.py
Normal 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()
|
||||||
|
|
@ -264,6 +264,42 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(cfg.environment, "staging")
|
self.assertEqual(cfg.environment, "staging")
|
||||||
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ from .engine_client import EngineError
|
||||||
from .routes import sessions
|
from .routes import sessions
|
||||||
from .routes import voice as voice_routes
|
from .routes import voice as voice_routes
|
||||||
from .services import orchestrator, persona as persona_service, state_machine
|
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
|
from .store import InProcSession, store
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -81,6 +81,152 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(caught.exception.status_code, 503)
|
self.assertEqual(caught.exception.status_code, 503)
|
||||||
self.assertEqual(sess.turns, [])
|
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:
|
async def test_stream_turn_engine_error_event_does_not_append_partial_turns(self) -> None:
|
||||||
principal = _principal()
|
principal = _principal()
|
||||||
sess = _session(principal)
|
sess = _session(principal)
|
||||||
|
|
@ -138,6 +284,79 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(sess.turns, [])
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
73
apps/api/app/test_state_machine_resistance.py
Normal 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()
|
||||||
223
apps/api/app/test_voice_service.py
Normal 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()
|
||||||
294
apps/api/app/test_voice_ws.py
Normal 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()
|
||||||
|
|
@ -33,6 +33,10 @@ BASE_ARGS = [
|
||||||
"--output-format", "stream-json",
|
"--output-format", "stream-json",
|
||||||
"--verbose",
|
"--verbose",
|
||||||
"--dangerously-skip-permissions",
|
"--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:
|
if FALLBACK_MODEL:
|
||||||
args += ["--fallback-model", FALLBACK_MODEL]
|
args += ["--fallback-model", FALLBACK_MODEL]
|
||||||
if self.system_prompt:
|
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(
|
self.proc = await asyncio.create_subprocess_exec(
|
||||||
*args,
|
*args,
|
||||||
stdin=asyncio.subprocess.PIPE,
|
stdin=asyncio.subprocess.PIPE,
|
||||||
|
|
@ -327,7 +333,6 @@ class GwMessage(BaseModel):
|
||||||
class GwGenerateReq(BaseModel):
|
class GwGenerateReq(BaseModel):
|
||||||
ai_role: AIRole = "client"
|
ai_role: AIRole = "client"
|
||||||
messages: list[GwMessage]
|
messages: list[GwMessage]
|
||||||
tier: Literal["client", "feedback", "fast"] = "client"
|
|
||||||
model: Optional[str] = None
|
model: Optional[str] = None
|
||||||
max_tokens: int = 1024
|
max_tokens: int = 1024
|
||||||
temperature: float = 0.7
|
temperature: float = 0.7
|
||||||
|
|
@ -455,7 +460,14 @@ async def v1_stream(req: GwGenerateReq):
|
||||||
yield f"event: error\ndata: {err}\n\n"
|
yield f"event: error\ndata: {err}\n\n"
|
||||||
else:
|
else:
|
||||||
meta = json.dumps(
|
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,
|
ensure_ascii=False,
|
||||||
)
|
)
|
||||||
yield f"event: done\ndata: {meta}\n\n"
|
yield f"event: done\ndata: {meta}\n\n"
|
||||||
|
|
|
||||||
|
|
@ -75,7 +75,7 @@ class GatewayModelTest(unittest.TestCase):
|
||||||
self.assertIs(ephemeral, True)
|
self.assertIs(ephemeral, True)
|
||||||
self.assertEqual(session.model, "request-model")
|
self.assertEqual(session.model, "request-model")
|
||||||
self.assertEqual(_model_arg(captured[0]), "request-model")
|
self.assertEqual(_model_arg(captured[0]), "request-model")
|
||||||
self.assertIn("--append-system-prompt", captured[0])
|
self.assertIn("--system-prompt", captured[0])
|
||||||
finally:
|
finally:
|
||||||
asyncio.run(session.close())
|
asyncio.run(session.close())
|
||||||
|
|
||||||
|
|
@ -120,6 +120,98 @@ class GatewayModelTest(unittest.TestCase):
|
||||||
finally:
|
finally:
|
||||||
asyncio.run(session.close())
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,14 @@
|
||||||
# 설치: pip install -r requirements-rag.txt (base requirements.txt 위에 추가)
|
# 설치: pip install -r requirements-rag.txt (base requirements.txt 위에 추가)
|
||||||
|
|
||||||
# 임베딩 + 리랭커 (BGE-M3 dense/sparse/colbert + bge-reranker-v2-m3 단일 패키지)
|
# 임베딩 + 리랭커 (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
|
torch>=2.1
|
||||||
transformers>=4.40
|
transformers==4.44.2
|
||||||
|
|
||||||
# (선택) sentence-transformers — 보조 임베딩/유틸. FlagEmbedding 만으로도 BGE-M3 동작.
|
# (선택) sentence-transformers — 보조 임베딩/유틸. FlagEmbedding 만으로도 BGE-M3 동작.
|
||||||
sentence-transformers>=2.7
|
sentence-transformers>=2.7
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ npm run dev # http://localhost:5173 (개발 서버, /api → :8000 프록
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run build # tsc -b + vite build → dist/
|
npm run build # tsc -b + vite build → dist/
|
||||||
|
npm run generate:live2d-assets
|
||||||
npm run preview # 빌드 결과 미리보기
|
npm run preview # 빌드 결과 미리보기
|
||||||
npm run typecheck # tsc --noEmit (타입 체크만)
|
npm run typecheck # tsc --noEmit (타입 체크만)
|
||||||
```
|
```
|
||||||
|
|
@ -30,8 +31,11 @@ npm run typecheck # tsc --noEmit (타입 체크만)
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `VITE_API_BASE` | `/api` | API 베이스. 개발은 vite proxy, 프로덕션은 nginx가 백엔드로 라우팅 |
|
| `VITE_API_BASE` | `/api` | API 베이스. 개발은 vite proxy, 프로덕션은 nginx가 백엔드로 라우팅 |
|
||||||
|
|
||||||
세션 아바타는 SVG/persona 렌더러만 사용한다. Mao/Haru 같은 샘플 Live2D 자산과
|
세션 아바타는 first-party SVG Live2D parameter rig를 사용한다. P4~P7 모델은
|
||||||
Pixi/Cubism 런타임은 공개 배포물에서 제거했다.
|
`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.
|
ProgressBar Field/Input Dot Icon(inline svg) SectionHead.
|
||||||
배럴: index.ts → import { Button, ... } from "../components/ui"
|
배럴: index.ts → import { Button, ... } from "../components/ui"
|
||||||
shell/ AppShell Sidebar Topbar (§6.3 공통 셸).
|
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 화면.
|
pages/ Login + learner/teacher/admin/settings/session 화면.
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -129,6 +129,93 @@ async function openAdminAndReadUsers(page: Page) {
|
||||||
return (await usersResponse.json()) as AdminUsersResponse;
|
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.describe("admin route", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await useRealApi(page);
|
await useRealApi(page);
|
||||||
|
|
@ -229,6 +316,7 @@ test.describe("admin route", () => {
|
||||||
const card = page.locator(".ad-user").filter({ hasText: email });
|
const card = page.locator(".ad-user").filter({ hasText: email });
|
||||||
await expect(card).toBeVisible();
|
await expect(card).toBeVisible();
|
||||||
await expect(card).toContainText(displayName);
|
await expect(card).toContainText(displayName);
|
||||||
|
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
|
||||||
|
|
||||||
const nextName = `교수자 ${testInfo.project.name}`;
|
const nextName = `교수자 ${testInfo.project.name}`;
|
||||||
const nameInput = card.getByLabel(`${email} 표시 이름`);
|
const nameInput = card.getByLabel(`${email} 표시 이름`);
|
||||||
|
|
@ -293,12 +381,39 @@ test.describe("admin route", () => {
|
||||||
await expect(page.locator(".ad-root")).toHaveCount(0);
|
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 page.setViewportSize({ width: 390, height: 844 });
|
||||||
await signInAsAdmin(page);
|
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 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);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
117
apps/web/e2e/avatar-expression-lab.spec.ts
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
220
apps/web/e2e/avatar-expression.spec.ts
Normal 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("온화함");
|
||||||
|
});
|
||||||
|
});
|
||||||
290
apps/web/e2e/layout-visual-gate.spec.ts
Normal 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 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -69,6 +69,25 @@ async function expectVisibleResumeLoadedSignal(page: import("@playwright/test").
|
||||||
).toBeTruthy();
|
).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.describe("learner app shell and session launcher", () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
await useRealApi(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 signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
|
||||||
await page.goto("/learn");
|
await page.goto("/learn");
|
||||||
const personas = await fetchAvailablePersonas(page);
|
const personas = await fetchAvailablePersonas(page);
|
||||||
|
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
|
||||||
|
|
||||||
const launcher = page.getByRole("listbox", { name: "연습 페르소나" });
|
const launcher = page.getByRole("listbox", { name: "연습 페르소나" });
|
||||||
await expect(launcher).toBeVisible();
|
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) {
|
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.code) })).toBeVisible();
|
||||||
await expect(launcher.getByRole("option", { name: new RegExp(persona.display_name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")) })).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.getByText(/최근 연습/)).toHaveCount(0);
|
||||||
await expect(page.locator(".vg-nav")).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.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.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 expectNoLearnerInternalCopy(page);
|
||||||
await expectNoDocumentOverflow(page);
|
|
||||||
await expectNoHorizontalOverflow(page);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test("routes the launcher CTA to the selected database persona", async ({ 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();
|
expect(endResponse.ok(), await endResponse.text()).toBeTruthy();
|
||||||
|
|
||||||
await page.goto("/learn");
|
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: "기록" })).toBeVisible();
|
await expect(page.getByRole("button", { name: "기록" })).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2);
|
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: "기록" })).toBeInViewport();
|
await expect(page.getByRole("button", { name: "기록" })).toBeInViewport();
|
||||||
await expect(page.getByRole("button", { name: "다시 연습" }).first()).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 signInAsLearner(page);
|
||||||
await page.goto("/learn/session/P9");
|
await page.goto("/learn/session/P9");
|
||||||
|
|
||||||
await expect(page.getByText(/P9 페르소나는 현재 연습 목록에 없습니다/)).toBeVisible();
|
|
||||||
await expect(page.getByText("페르소나 P9")).toHaveCount(0);
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole("heading", { name: "연습 대상 정보를 확인하고 있습니다." }),
|
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 expect(page.getByRole("button", { name: "회기 시작" })).toBeDisabled();
|
||||||
await expectNoDocumentOverflow(page);
|
await expectNoDocumentOverflow(page);
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
|
|
|
||||||
101
apps/web/e2e/live2d-assets.spec.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -55,16 +55,15 @@ async function expectMobileContextIfNarrow(page: Page) {
|
||||||
const isPhoneLayout = await page.evaluate(() =>
|
const isPhoneLayout = await page.evaluate(() =>
|
||||||
window.matchMedia("(max-width: 880px)").matches,
|
window.matchMedia("(max-width: 880px)").matches,
|
||||||
);
|
);
|
||||||
|
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
|
||||||
if (isPhoneLayout) {
|
if (isPhoneLayout) {
|
||||||
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
|
await expect(page.locator(".sx-page--active .sx-col-left")).toBeHidden();
|
||||||
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
|
|
||||||
} else {
|
} else {
|
||||||
const feedbackSurface = page.locator(".sx-page--active .sx-col-right");
|
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
|
||||||
await expect(feedbackSurface).toBeVisible();
|
|
||||||
await expect(feedbackSurface).toBeInViewport();
|
|
||||||
}
|
}
|
||||||
const mobileContext = page.getByLabel("현재 회기 요약");
|
const mobileContext = page.getByLabel("현재 회기 요약");
|
||||||
await expect(mobileContext).toBeVisible();
|
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("내담자");
|
await expect(mobileContext).toContainText("내담자");
|
||||||
await expect(mobileContext).toContainText("마이크");
|
await expect(mobileContext).toContainText("마이크");
|
||||||
|
|
@ -122,6 +121,225 @@ async function expectSessionControlsInsideViewport(page: Page) {
|
||||||
).toEqual([]);
|
).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) {
|
async function expectActiveSessionUsableLayout(page: Page) {
|
||||||
const result = await page.evaluate(() => {
|
const result = await page.evaluate(() => {
|
||||||
const grid = document.querySelector<HTMLElement>(".sx-page--active .sx-grid");
|
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 stageOverflow = stage.scrollHeight - stage.clientHeight;
|
||||||
const transcriptOverflow = transcript.scrollHeight - transcript.clientHeight;
|
const transcriptOverflow = transcript.scrollHeight - transcript.clientHeight;
|
||||||
const phone = window.matchMedia("(max-width: 880px)").matches;
|
const phone = window.matchMedia("(max-width: 880px)").matches;
|
||||||
|
const centerHeight = centerRect.height;
|
||||||
|
const stageHeight = stageRect.height;
|
||||||
|
const transcriptHeight = transcriptRect.height;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
phone,
|
phone,
|
||||||
gridWidth: Math.round(gridRect.width),
|
gridWidth: Math.round(gridRect.width),
|
||||||
centerWidth: Math.round(centerRect.width),
|
centerWidth: Math.round(centerRect.width),
|
||||||
|
centerHeight: Math.round(centerHeight),
|
||||||
|
stageHeight: Math.round(stageHeight),
|
||||||
|
transcriptHeight: Math.round(transcriptHeight),
|
||||||
scrollHeight: Math.round(scroll.getBoundingClientRect().height),
|
scrollHeight: Math.round(scroll.getBoundingClientRect().height),
|
||||||
stageOverflow,
|
stageOverflow,
|
||||||
transcriptOverflow,
|
transcriptOverflow,
|
||||||
|
|
@ -170,6 +394,14 @@ async function expectActiveSessionUsableLayout(page: Page) {
|
||||||
).toBeLessThanOrEqual(2);
|
).toBeLessThanOrEqual(2);
|
||||||
}
|
}
|
||||||
expect(result.scrollHeight, `Transcript viewport too small: ${JSON.stringify(result)}`).toBeGreaterThanOrEqual(110);
|
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.stageOverflow, `Stage content clipped: ${JSON.stringify(result)}`).toBeLessThanOrEqual(4);
|
||||||
expect(result.transcriptOverflow, `Transcript chrome 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);
|
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 expectSessionPageHeightToMatchViewport(page);
|
||||||
await expectMobileContextIfNarrow(page);
|
await expectMobileContextIfNarrow(page);
|
||||||
await expectSessionControlsInsideViewport(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 }) => {
|
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 = [
|
const viewports = [
|
||||||
{ width: 1366, height: 768 },
|
{ width: 1366, height: 768 },
|
||||||
{ width: 1366, height: 720 },
|
{ width: 1366, height: 720 },
|
||||||
|
{ width: 1180, height: 768 },
|
||||||
|
{ width: 1100, height: 768 },
|
||||||
{ width: 1024, height: 768 },
|
{ width: 1024, height: 768 },
|
||||||
{ width: 1024, height: 640 },
|
{ width: 1024, height: 640 },
|
||||||
|
{ width: 900, height: 768 },
|
||||||
|
{ width: 881, height: 768 },
|
||||||
{ width: 820, height: 1180 },
|
{ width: 820, height: 1180 },
|
||||||
{ width: 390, height: 844 },
|
{ width: 390, height: 844 },
|
||||||
{ width: 375, height: 667 },
|
{ width: 375, height: 667 },
|
||||||
|
|
@ -233,19 +472,23 @@ test.describe("learner session full-screen layout", () => {
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
await expectNoLocalStageDemoControl(page);
|
await expectNoLocalStageDemoControl(page);
|
||||||
await expectSessionControlsInsideViewport(page);
|
await expectSessionControlsInsideViewport(page);
|
||||||
|
await expectNoVisibleSessionPanelOverlap(page);
|
||||||
|
await expectMainControlsUnclipped(page);
|
||||||
await expectSessionPageHeightToMatchViewport(page);
|
await expectSessionPageHeightToMatchViewport(page);
|
||||||
await expectActiveSessionUsableLayout(page);
|
await expectActiveSessionUsableLayout(page);
|
||||||
|
await expectRightPanelDoesNotIntersectSessionCore(page);
|
||||||
|
|
||||||
if (viewport.width <= 1180) {
|
if (viewport.width <= 1180) {
|
||||||
await expect(page.locator(".sx-page--active .sx-mobile-context")).toBeVisible();
|
await expect(page.locator(".sx-page--active .sx-mobile-context")).toBeVisible();
|
||||||
}
|
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
|
||||||
if (viewport.width > 880 && viewport.width <= 1180) {
|
} else {
|
||||||
await expect(page.locator(".sx-page--active .sx-col-right")).toBeVisible();
|
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-right")).toBeInViewport();
|
||||||
await expect(page.locator(".sx-page--active .sx-col-left")).toBeVisible();
|
|
||||||
}
|
}
|
||||||
if (viewport.width <= 880) {
|
if (viewport.width > 880 && viewport.width <= 1180) {
|
||||||
await expect(page.locator(".sx-page--active .sx-col-right")).toBeHidden();
|
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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
|
||||||
186
apps/web/e2e/session-mvp.spec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -37,6 +37,31 @@ async function createEndedSession(page: Page) {
|
||||||
return session.session_id;
|
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.describe("session review", () => {
|
||||||
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
|
test("renders server review data without legacy transcript fixtures", async ({ page }) => {
|
||||||
await signInAsLearner(page);
|
await signInAsLearner(page);
|
||||||
|
|
@ -60,6 +85,7 @@ test.describe("session review", () => {
|
||||||
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeDisabled();
|
||||||
await expect(page.getByText("32분 14초")).toHaveCount(0);
|
await expect(page.getByText("32분 14초")).toHaveCount(0);
|
||||||
await expect(page.getByText("시연")).toHaveCount(0);
|
await expect(page.getByText("시연")).toHaveCount(0);
|
||||||
|
await expectReviewRegionsReachable(page);
|
||||||
const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) =>
|
const filterMetrics = await page.locator(".sr-chip-toggle").evaluateAll((buttons) =>
|
||||||
buttons.map((button) => {
|
buttons.map((button) => {
|
||||||
const rect = button.getBoundingClientRect();
|
const rect = button.getBoundingClientRect();
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function slugFor(testInfo: TestInfo) {
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
for (const char of testInfo.title) {
|
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 ({
|
test("admin engine settings panel stays readable at a mobile viewport", async ({
|
||||||
page,
|
page,
|
||||||
}, testInfo) => {
|
}, testInfo) => {
|
||||||
|
|
@ -409,11 +643,13 @@ test.describe("settings page", () => {
|
||||||
const engine = page.locator("#set-engine");
|
const engine = page.locator("#set-engine");
|
||||||
await expect(engine).toBeVisible();
|
await expect(engine).toBeVisible();
|
||||||
await expect(engine.locator("[data-engine-mode]")).toHaveCount(4);
|
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)).toBeVisible();
|
||||||
await expect(engine.locator("input").nth(0)).toHaveValue(engineConfig!.engine_url);
|
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)).toBeVisible();
|
||||||
await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model);
|
await expect(engine.locator("input").nth(1)).toHaveValue(engineConfig!.model);
|
||||||
|
|
||||||
|
await expectNoSettingsControlClipping(page);
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -423,6 +659,7 @@ test.describe("settings page", () => {
|
||||||
|
|
||||||
await openSettings(page, { admin: true });
|
await openSettings(page, { admin: true });
|
||||||
|
|
||||||
|
await expectNoSettingsControlClipping(page);
|
||||||
await expectNoHorizontalOverflow(page);
|
await expectNoHorizontalOverflow(page);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
function isTeacherDashboardResponse(response: Response) {
|
||||||
const url = new URL(response.url());
|
const url = new URL(response.url());
|
||||||
return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard");
|
return response.request().method() === "GET" && url.pathname.endsWith("/teacher/dashboard");
|
||||||
|
|
@ -37,7 +75,99 @@ async function createEndedLearnerSession(page: Page) {
|
||||||
return session.session_id;
|
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.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 }) => {
|
test("renders real server sessions from server-owned rows", async ({ page }) => {
|
||||||
const sessionId = await createEndedLearnerSession(page);
|
const sessionId = await createEndedLearnerSession(page);
|
||||||
await signInAsTeacher(page);
|
await signInAsTeacher(page);
|
||||||
|
|
@ -59,23 +189,98 @@ test.describe("teacher console", () => {
|
||||||
await expectNoHorizontalOverflow(page);
|
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 }) => {
|
test("keeps long teacher lists in bounded panels", async ({ page }) => {
|
||||||
await createEndedLearnerSession(page);
|
await createEndedLearnerSession(page);
|
||||||
await signInAsTeacher(page);
|
await signInAsTeacher(page);
|
||||||
|
|
||||||
await page.goto("/teach");
|
await page.goto("/teach");
|
||||||
await expect(page.locator(".pf-list")).toBeVisible();
|
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 metrics = await page.evaluate(() => {
|
||||||
const list = document.querySelector<HTMLElement>(".pf-list");
|
const list = document.querySelector<HTMLElement>(".pf-list");
|
||||||
const table = document.querySelector<HTMLElement>(".pf-tablewrap");
|
const recent = document.querySelector<HTMLElement>(".pf-recent-list");
|
||||||
const header = document.querySelector<HTMLElement>(".pf-table th");
|
const header = document.querySelector<HTMLElement>(".pf-recent-head");
|
||||||
if (!list || !table || !header) {
|
if (!list || !recent || !header) {
|
||||||
throw new Error("teacher list panels were not rendered");
|
throw new Error("teacher list panels were not rendered");
|
||||||
}
|
}
|
||||||
const listStyle = window.getComputedStyle(list);
|
const listStyle = window.getComputedStyle(list);
|
||||||
const tableStyle = window.getComputedStyle(table);
|
const recentStyle = window.getComputedStyle(recent);
|
||||||
const headerStyle = window.getComputedStyle(header);
|
const headerStyle = window.getComputedStyle(header);
|
||||||
const doc = document.documentElement;
|
const doc = document.documentElement;
|
||||||
return {
|
return {
|
||||||
|
|
@ -83,21 +288,68 @@ test.describe("teacher console", () => {
|
||||||
viewportHeight: doc.clientHeight,
|
viewportHeight: doc.clientHeight,
|
||||||
listMaxHeight: listStyle.maxHeight,
|
listMaxHeight: listStyle.maxHeight,
|
||||||
listOverflowY: listStyle.overflowY,
|
listOverflowY: listStyle.overflowY,
|
||||||
tableMaxHeight: tableStyle.maxHeight,
|
recentMaxHeight: recentStyle.maxHeight,
|
||||||
tableOverflowY: tableStyle.overflowY,
|
recentOverflowY: recentStyle.overflowY,
|
||||||
headerPosition: headerStyle.position,
|
headerPosition: headerStyle.position,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(metrics.listMaxHeight).not.toBe("none");
|
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.listOverflowY);
|
||||||
expect(["auto", "scroll"]).toContain(metrics.tableOverflowY);
|
expect(["auto", "scroll"]).toContain(metrics.recentOverflowY);
|
||||||
expect(metrics.headerPosition).toBe("sticky");
|
expect(metrics.headerPosition).toBe("sticky");
|
||||||
expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200);
|
expect(metrics.docHeight - metrics.viewportHeight).toBeLessThanOrEqual(2200);
|
||||||
await expectNoHorizontalOverflow(page);
|
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 }) => {
|
test("denies learner access to the teacher dashboard API and UI", async ({ page }) => {
|
||||||
await signInAsLearner(page);
|
await signInAsLearner(page);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,35 @@ interface SpawnedApi {
|
||||||
stop: () => Promise<void>;
|
stop: () => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface SpawnedWeb {
|
||||||
|
baseURL: string;
|
||||||
|
logs: () => string;
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
interface VoiceProbe {
|
interface VoiceProbe {
|
||||||
code: number;
|
code: number;
|
||||||
messages: string[];
|
messages: string[];
|
||||||
binaryChunks: number;
|
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
|
// 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.
|
// so the voice provider cascade can be exercised without a Postgres dependency.
|
||||||
const SEEDED_VOICE_PERSONA_CODE = "P1";
|
const SEEDED_VOICE_PERSONA_CODE = "P1";
|
||||||
|
|
@ -137,9 +160,11 @@ async function waitForApi(baseURL: string, proc: ChildProcessWithoutNullStreams)
|
||||||
async function startApi({
|
async function startApi({
|
||||||
engineURL,
|
engineURL,
|
||||||
openAIBaseURL,
|
openAIBaseURL,
|
||||||
|
frontendBaseURL = "http://localhost:5173",
|
||||||
}: {
|
}: {
|
||||||
engineURL: string;
|
engineURL: string;
|
||||||
openAIBaseURL: string;
|
openAIBaseURL: string;
|
||||||
|
frontendBaseURL?: string;
|
||||||
}): Promise<SpawnedApi> {
|
}): Promise<SpawnedApi> {
|
||||||
const port = await freePort();
|
const port = await freePort();
|
||||||
const baseURL = `http://127.0.0.1:${port}`;
|
const baseURL = `http://127.0.0.1:${port}`;
|
||||||
|
|
@ -181,8 +206,8 @@ async function startApi({
|
||||||
ENGINE_CONNECT_TIMEOUT: "2",
|
ENGINE_CONNECT_TIMEOUT: "2",
|
||||||
OPENAI_API_KEY: "e2e-fake-key",
|
OPENAI_API_KEY: "e2e-fake-key",
|
||||||
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
|
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
|
||||||
FRONTEND_BASE_URL: "http://localhost:5173",
|
FRONTEND_BASE_URL: frontendBaseURL,
|
||||||
CORS_ORIGINS: '["http://localhost:5173"]',
|
CORS_ORIGINS: JSON.stringify([frontendBaseURL]),
|
||||||
},
|
},
|
||||||
windowsHide: true,
|
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> {
|
async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: string): Promise<VoiceProbe> {
|
||||||
return page.evaluate(
|
return page.evaluate(
|
||||||
({ apiBase, sid }) =>
|
({ 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.describe("voice cascade success path", () => {
|
||||||
test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({
|
test("runs STT, client turn, TTS, and audio chunks against controlled providers @single-run", async ({
|
||||||
page,
|
page,
|
||||||
|
|
@ -357,4 +625,188 @@ test.describe("voice cascade success path", () => {
|
||||||
await openai.close();
|
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();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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.", {
|
return new Response("Live2D demo assets are not part of this deployment.", {
|
||||||
status: 404,
|
status: 404,
|
||||||
headers: {
|
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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
|
"generate:live2d-assets": "node scripts/generate-live2d-assets.mjs",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"typecheck": "tsc -b",
|
"typecheck": "tsc -b",
|
||||||
"lint": "tsc -b",
|
"lint": "tsc -b",
|
||||||
|
|
|
||||||
BIN
apps/web/public/avatar/seoyeon/anxious.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
apps/web/public/avatar/seoyeon/eyes-closed.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
apps/web/public/avatar/seoyeon/neutral.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/base-faceless.png
Normal file
|
After Width: | Height: | Size: 456 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/brow-anxious.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/brow-neutral.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/brow-sad.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/brow-startled.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/brow-tired.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/brow-warm.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/ear-left.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/ear-right.png
Normal file
|
After Width: | Height: | Size: 17 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/eyelid-closed.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/eyes-anxious.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/eyes-neutral.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/eyes-sad.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/eyes-startled.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/eyes-tired.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/eyes-warm.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/face-faceless.png
Normal file
|
After Width: | Height: | Size: 186 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/forehead.png
Normal file
|
After Width: | Height: | Size: 55 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/hair-back.png
Normal file
|
After Width: | Height: | Size: 148 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/hair-bangs.png
Normal file
|
After Width: | Height: | Size: 113 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/hair-left.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/hair-right.png
Normal file
|
After Width: | Height: | Size: 27 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/mouth-anxious.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/mouth-neutral.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/mouth-open.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/mouth-sad.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/mouth-startled.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/mouth-tired.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/mouth-warm.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/neck.png
Normal file
|
After Width: | Height: | Size: 64 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/nose-neutral.png
Normal file
|
After Width: | Height: | Size: 12 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/shoulders.png
Normal file
|
After Width: | Height: | Size: 211 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/upperface-anxious.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/upperface-neutral.png
Normal file
|
After Width: | Height: | Size: 3.9 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/upperface-sad.png
Normal file
|
After Width: | Height: | Size: 212 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/upperface-startled.png
Normal file
|
After Width: | Height: | Size: 206 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/upperface-tired.png
Normal file
|
After Width: | Height: | Size: 200 KiB |
BIN
apps/web/public/avatar/seoyeon/parts/upperface-warm.png
Normal file
|
After Width: | Height: | Size: 198 KiB |
BIN
apps/web/public/avatar/seoyeon/sad.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
apps/web/public/avatar/seoyeon/speaking.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
apps/web/public/avatar/seoyeon/startled.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
apps/web/public/avatar/seoyeon/tired.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
BIN
apps/web/public/avatar/seoyeon/warm.png
Normal file
|
After Width: | Height: | Size: 508 KiB |
30
apps/web/public/live2d/personas/index.json
Normal 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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||