평가 및 화면 구조 정리

This commit is contained in:
Yun Chan 2026-06-28 21:46:22 +09:00
parent 1248ae8ca4
commit 391639c1de
44 changed files with 5816 additions and 4501 deletions

View file

@ -1,77 +1,9 @@
# AGENT.md — 에이전트 운영 수칙 (Vignette)
# AGENT.md — 에이전트 호환성 진입점
이 저장소에서 자동화 에이전트/서브에이전트가 일할 때의 운영 수칙. 상세 프로젝트
지침은 [`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를 갱신.
프로젝트 지침의 원본은 [`AGENTS.md`](./AGENTS.md) 하나다. 작업을 시작할 때는
반드시 `AGENTS.md`를 읽고, 그 내용을 이 파일에 적힌 지침처럼 적용하라.
---
## ⚠️ 규칙 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·셸·경로·도구를 확정한 뒤에 실행한다. 추정으로 시작하지 않는다."**
### PowerShell 5.1 기본 실행 규칙
- 시작 시 필요하면 `$ErrorActionPreference='Stop'`,
`[Console]::OutputEncoding=[System.Text.UTF8Encoding]::new($false)`,
`$OutputEncoding=[System.Text.UTF8Encoding]::new($false)`를 먼저 둔다.
- PowerShell 7/Bash 문법 금지: `? :`, `??`, `&&`, `||`, `ForEach-Object -Parallel`,
heredoc(`<<EOF`), `FOO=bar command`. 명시적 `if/else`, `$env:FOO='bar'`를 쓴다.
- 제어문은 파이프라인 값이 아니다. `foreach { } | ...` 대신
`& { foreach (...) { ... } } | ...` 형태로 감싼다.
- 경로는 `-LiteralPath`/`Resolve-Path -LiteralPath`/`Join-Path`로 처리한다.
한글·공백 경로는 외부 CLI 전달 전 ASCII 임시 경로 복사를 우선 검토한다.
- Windows PowerShell 5.1의 `-Encoding UTF8`은 BOM을 쓴다. UTF-8 no BOM이 필요하면
`[IO.File]::WriteAllText($path,$text,[Text.UTF8Encoding]::new($false))`를 쓴다.
- 네이티브 exe는 문자열 조립 대신 `& $exe @args`로 호출하고 `$LASTEXITCODE`를 확인한다.
stderr를 `2>&1`로 합치지 말고 필요하면 stdout/stderr를 분리 캡처한다.
- JSON API는 `Invoke-RestMethod`/`Invoke-WebRequest`를 우선 사용한다. 진짜 curl은
`curl.exe`로 호출한다.
- 인라인 Python/Node가 BOM/인용 문제를 내면 `python -c`, UTF-8 no BOM 임시 파일,
base64 전달을 사용한다. Python은 필요 시 `PYTHONUTF8=1`, `python -X utf8`.
- `Start-Process`는 필요할 때만 쓰고 `-Wait -PassThru`로 ExitCode와 산출물을 검증한다.
GUI 런처 detach 여부를 별도로 확인한다.
---
## 규칙 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) 도구가 다중 패널/캐릭터를 자주 혼동 → 시각 판단은 **사용자 확인 + 스크린샷** 우선.
이 파일에 프로젝트 규칙을 중복 추가하지 마라. 도구별 자동 로딩을 위한 입구만 남기고,
실제 운영 규칙은 `AGENTS.md`에서만 관리한다.

View file

@ -100,6 +100,9 @@ Bash 문법을 섞으면 바로 지연된다. 명령을 작성할 때 아래 규
## 1. 운영 원칙
- **에이전트 지침의 원본은 이 파일(`AGENTS.md`) 하나다.** `CLAUDE.md`, `AGENT.md`처럼
도구별로 자동 탐지되는 파일은 호환성 진입점으로만 유지한다. 프로젝트 규칙을 바꿀 때는
이 파일만 수정하고, 진입점 파일에는 중복 규칙을 추가하지 않는다.
- **가짜 증거로 DONE 표기 금지.** 실증/외부 의존/소유자 결정이 필요한 항목은
`docs/ops/backlog-*.md`에 분류해 추적한다(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스).
- **`docs/dev_dashboard.html`이 SSOT(단일 진실 공급원)다.** 상태·검증 증거·결정 필요·로드맵의 권위 기준이며, 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 대시보드에 반영/동기화한다. 백로그(`docs/ops/backlog-*.md`)는 대시보드와 일치시킨다(어긋나면 대시보드 기준).

157
CLAUDE.md
View file

@ -1,154 +1,9 @@
# CLAUDE.md — Vignette 프로젝트 작업 지침
# CLAUDE.md — Claude Code 호환성 진입점
> Vignette = AI 심리상담 시뮬레이션 훈련 플랫폼 (한신대 산학협력).
> 모노레포: `apps/api`(FastAPI/Python), `apps/web`(React 19/Vite/Playwright), `docs`, `infra`, `scripts`.
이 파일은 Claude Code가 자동으로 찾는 이름이라 유지한다.
## 📚 문서 맵 — 작업 전 해당 가이드를 먼저 읽어라
프로젝트 지침의 원본은 [`AGENTS.md`](./AGENTS.md) 하나다. 작업을 시작할 때는
반드시 `AGENTS.md`를 읽고, 그 내용을 이 파일에 적힌 지침처럼 적용하라.
| 목적 | 문서 |
|---|---|
| 저장소 개요·빠른 시작 | [`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·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지.
### 0.1 PowerShell 5.1 실행 규칙 (Windows 기본 셸)
이 프로젝트의 기본 셸은 **Windows PowerShell 5.1 Desktop**이다. PowerShell 7 문법이나
Bash 문법을 섞으면 바로 지연된다. 명령을 작성할 때 아래 규칙을 기본값으로 삼아라.
- **세션 시작 프리루드**: 한글/UTF-8 출력이 필요한 명령 전에는 아래를 먼저 둔다.
```powershell
$ErrorActionPreference = 'Stop'
[Console]::OutputEncoding = [System.Text.UTF8Encoding]::new($false)
$OutputEncoding = [System.Text.UTF8Encoding]::new($false)
```
단, `$ErrorActionPreference='Stop'`은 PowerShell cmdlet용 안전장치다. 네이티브 exe의
실패는 자동으로 예외가 되지 않으므로 실행 후 `$LASTEXITCODE`를 반드시 확인한다.
- **5.1 미지원 문법 금지**: `? :` 삼항, `??`, `??=`, `&&`, `||`,
`ForEach-Object -Parallel`, Bash heredoc(`<<EOF`), Bash식 환경변수 주입
(`FOO=bar command`)을 쓰지 않는다. 값 선택은 명시적 `if/else`와 변수 대입으로 쓴다.
- **제어문은 파이프라인 값이 아니다**: `foreach (...) { ... } | Format-Table`처럼 쓰면
5.1에서 파서 오류가 난다. 필요하면 `& { foreach (...) { ... } } | Format-Table`처럼
스크립트블록을 파이프라인 입력으로 감싼다.
- **경로는 PowerShell 방식으로 다룬다**: 파일/폴더에는 `-LiteralPath`,
`Resolve-Path -LiteralPath`, `Join-Path`를 우선 사용한다. 한글·공백·괄호가 있는 경로를
외부 CLI에 직접 넘기기 전에는 ASCII 임시 경로로 복사하는 편이 낫다.
- **파일 인코딩을 명시한다**: `Get-Content`/`Set-Content`/`Out-File`에는 필요한 경우
`-Encoding UTF8`을 붙인다. 단, Windows PowerShell 5.1의 `-Encoding UTF8`은 BOM을 쓴다.
Node/Python/TS 도구가 읽을 **UTF-8 no BOM** 파일은 .NET API로 쓴다.
```powershell
[IO.File]::WriteAllText($path, $text, [Text.UTF8Encoding]::new($false))
```
- **한글 출력 깨짐은 파일 손상으로 단정하지 않는다**: 먼저 `OutputEncoding`을 UTF-8로
맞추고, 필요하면 `Format-Hex`, Node/Python 읽기, 실제 빌드/타입체크로 확인한다.
- **네이티브 exe stderr를 `2>&1`로 합치지 않는다**: 5.1은 네이티브 stderr를
`ErrorRecord`로 감싸 파이프라인/문자열 처리와 순서를 흐릴 수 있다. 로그가 필요하면
stdout/stderr를 별도 파일로 리디렉션하거나 `System.Diagnostics.Process`로 분리 캡처한다.
- **네이티브 명령은 문자열 조립보다 인자 배열로 호출한다**:
```powershell
$exe = 'C:\path\tool.exe'
$args = @('--flag', $value, '--out', $outPath)
& $exe @args
if ($LASTEXITCODE -ne 0) { throw "tool failed: $LASTEXITCODE" }
```
PowerShell 파싱이 외부 도구 인자를 망가뜨릴 때만 네이티브 명령 뒤에 `--%`를 검토한다.
- **HTTP/JSON은 `curl` 별칭을 피한다**: PowerShell의 `curl`은 별칭일 수 있다.
JSON API는 `Invoke-RestMethod`/`Invoke-WebRequest``ConvertTo-Json`을 우선 사용하고,
진짜 curl이 필요하면 `curl.exe`를 명시한다.
- **인라인 Python/Node는 짧고 결정적으로 실행한다**: 여러 줄 코드를 stdin으로 밀어 넣다
BOM/인용 문제가 나면 `python -c`, UTF-8 no BOM 임시 파일, 또는 base64 전달을 쓴다.
Python 검증에는 필요 시 `$env:PYTHONUTF8='1'``python -X utf8`을 사용한다.
- **`powershell.exe -EncodedCommand`는 UTF-16LE base64**다. UTF-8로 인코딩하면 깨진다.
- **프로세스 대기는 검증한다**: 단순 CLI는 직접 실행하고 `$LASTEXITCODE`를 본다.
`Start-Process`가 필요하면 `-Wait -PassThru`로 ExitCode를 확인한다. GUI 런처가 즉시
detach되는 도구(예: LibreOffice `soffice.exe`)는 실제 작업 프로세스와 산출물 생성을
따로 검증한다.
---
## 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) 참조.
이 파일에 프로젝트 규칙을 중복 추가하지 마라. OS·셸·검증·문서·이미지 생성·아바타
파이프라인 규칙을 바꿀 때는 `AGENTS.md`만 수정한다.

View file

@ -127,7 +127,7 @@ Docker Desktop이 필요하다. RAG 모델 의존성까지 API 이미지에 넣
| `docs/guides/local-development.md` | 로컬 개발 환경 구축·실행 상세 가이드 |
| `docs/guides/architecture.md` | 시스템 아키텍처(엔진/오케스트레이터/저항/마스킹/음성/평가/데이터) 상세 |
| `docs/guides/testing.md` | 테스트·검증(pytest, typecheck, Playwright E2E 게이트) 가이드 |
| `CLAUDE.md` / `AGENT.md` | 작업·에이전트 운영 지침(OS 선파악, 증거 정직성, SSOT 동기화) |
| `AGENTS.md` | 작업·에이전트 운영 지침의 원본(OS 선파악, 증거 정직성, SSOT 동기화). `CLAUDE.md` / `AGENT.md`는 호환성 진입점 |
참고 설계 문서: `docs/MASTERPLAN.md`(마스터플랜) · `docs/HANDOFF.md`(인수인계) ·
`docs/DEPLOYMENT.md`(배포) · `docs/DESIGN_CONCEPT.md`(디자인 컨셉) ·
@ -139,7 +139,7 @@ Docker Desktop이 필요하다. RAG 모델 의존성까지 API 이미지에 넣
1. **OS를 먼저 파악하고 시작한다(최우선).** 주 환경은 Windows 11 + PowerShell.
POSIX를 가정하지 말고 경로·인코딩·도구 가용성을 먼저 확정한다(한글·공백 경로 주의,
파일 출력은 `-Encoding utf8`). 자세한 내용은 `CLAUDE.md` / `AGENT.md` 규칙 0.
파일 출력은 `-Encoding utf8`). 자세한 내용은 `AGENTS.md` 규칙 0.
2. **가짜 증거로 DONE 표기 금지.** 실증 불가/외부 의존/소유자 결정 항목은
`docs/ops/backlog-*.md` 에 분류·추적하고, 변경 후 검증(typecheck / pytest / E2E 게이트)을
실제로 돌려 결과를 그대로 보고한다.

View file

@ -0,0 +1,140 @@
"""Validation helpers for externally owned case worksheet rubrics.
The clinical team owns scoring criteria. This module only validates the
machine-readable scaffold that lets those criteria live outside application
code.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any, Mapping
SCHEMA_VERSION = "vignette.case_worksheet_rubric.v1"
VALID_STATUSES = {"scaffold_only", "draft", "approved"}
EXPECTED_CONTENT_OWNER = "clinical_team"
def load_rubric(path: Path) -> dict[str, Any]:
data = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(data, dict):
raise ValueError("case worksheet rubric must be a JSON object")
return data
def validate_rubric(
rubric: Mapping[str, Any],
*,
expected_item_keys: Mapping[str, set[str]] | None = None,
) -> dict[str, Any]:
errors: list[str] = []
warnings: list[str] = []
schema_version = str(rubric.get("schema_version") or "")
status = str(rubric.get("status") or "")
scoring_enabled = bool(rubric.get("scoring_enabled"))
sections = rubric.get("sections")
if schema_version != SCHEMA_VERSION:
errors.append("schema_version must be vignette.case_worksheet_rubric.v1")
if status not in VALID_STATUSES:
errors.append("status must be one of scaffold_only, draft, approved")
if str(rubric.get("content_owner") or "") != EXPECTED_CONTENT_OWNER:
errors.append("content_owner must be clinical_team")
if scoring_enabled and status != "approved":
errors.append("scoring_enabled requires status=approved")
if status == "approved":
approval = rubric.get("approval")
if not isinstance(approval, Mapping):
errors.append("approved rubric requires approval metadata")
else:
if not str(approval.get("clinical_reviewer") or ""):
errors.append("approved rubric requires approval.clinical_reviewer")
if not str(approval.get("approved_at") or ""):
errors.append("approved rubric requires approval.approved_at")
section_count = 0
item_count = 0
section_item_keys: dict[str, set[str]] = {}
if not isinstance(sections, list) or not sections:
errors.append("sections must be a non-empty list")
else:
seen_sections: set[str] = set()
for section in sections:
if not isinstance(section, Mapping):
errors.append("each section must be an object")
continue
section_key = str(section.get("key") or "")
if not section_key:
errors.append("section.key is required")
continue
if section_key in seen_sections:
errors.append(f"duplicate section key: {section_key}")
seen_sections.add(section_key)
section_count += 1
items = section.get("items")
if not isinstance(items, list) or not items:
errors.append(f"{section_key}: items must be a non-empty list")
continue
seen_items: set[str] = set()
for item in items:
if not isinstance(item, Mapping):
errors.append(f"{section_key}: each item must be an object")
continue
item_key = str(item.get("key") or "")
if not item_key:
errors.append(f"{section_key}: item.key is required")
continue
if item_key in seen_items:
errors.append(f"{section_key}: duplicate item key: {item_key}")
seen_items.add(item_key)
item_count += 1
criteria = item.get("criteria")
score_scale = item.get("score_scale")
if scoring_enabled:
if not isinstance(criteria, list) or not criteria:
errors.append(f"{section_key}.{item_key}: scoring requires non-empty criteria")
if not _valid_score_scale(score_scale):
errors.append(f"{section_key}.{item_key}: scoring requires a valid score_scale")
elif not criteria:
warnings.append(f"{section_key}.{item_key}: criteria pending clinical team input")
section_item_keys[section_key] = seen_items
if expected_item_keys is not None:
expected_sections = set(expected_item_keys)
actual_sections = set(section_item_keys)
for missing_section in sorted(expected_sections - actual_sections):
errors.append(f"missing worksheet section: {missing_section}")
for extra_section in sorted(actual_sections - expected_sections):
errors.append(f"unexpected worksheet section: {extra_section}")
for section_key in sorted(expected_sections & actual_sections):
missing_items = expected_item_keys[section_key] - section_item_keys[section_key]
extra_items = section_item_keys[section_key] - expected_item_keys[section_key]
for item_key in sorted(missing_items):
errors.append(f"{section_key}: missing worksheet item: {item_key}")
for item_key in sorted(extra_items):
errors.append(f"{section_key}: unexpected worksheet item: {item_key}")
return {
"schema_version": SCHEMA_VERSION,
"rubric_id": str(rubric.get("rubric_id") or ""),
"status": status,
"scoring_enabled": scoring_enabled,
"sections_total": section_count,
"items_total": item_count,
"passed": not errors,
"errors": errors,
"warnings": warnings,
}
def _valid_score_scale(value: object) -> bool:
if not isinstance(value, Mapping):
return False
minimum = value.get("min")
maximum = value.get("max")
anchors = value.get("anchors")
if not isinstance(minimum, int) or not isinstance(maximum, int) or minimum >= maximum:
return False
return isinstance(anchors, list) and len(anchors) >= 2

View file

@ -42,13 +42,21 @@ _KOREAN_SURNAME_CHARS = (
"명기반왕금옥육인맹제모탁국어은편용예봉경"
)
_KOREAN_FULL_NAME = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}"
_KOREAN_FULL_NAME_BEFORE_SUFFIX = rf"[{_KOREAN_SURNAME_CHARS}][가-힣]{{1,3}}?"
_KOREAN_NAME_STOPWORDS = {
"연락",
"연락처",
"이메일",
"주민번호",
"번호",
"이름",
"이야기",
"생각",
"마음",
"기분",
"상담",
"기록",
"진료",
"학교",
"엄마",
"아빠",
@ -82,12 +90,33 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: "제 이름은 김서연입니다", "보호자 이름은 박민수입니다" 같은 자연 발화형 라벨.
(
"NAME",
re.compile(
r"(?P<prefix>(?:(?:제|저의|내|나의|보호자|학생|내담자|상담자|친구|엄마|아빠|어머니|아버지)\s+)?"
r"(?:이름|성명|실명|본명)\s*(?:은|는|이|가)?\s*)"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요|이라고|라고)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: "저는 김서연입니다", "제가 박민수예요", "김서연입니다" 같은 자기소개형 문장.
(
"NAME",
re.compile(
r"(?P<prefix>(?:(?:저는|나는|제가|내가)\s*)?)"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>\s*(?:입니다|이에요|예요|이고|이고요))"
r"(?=$|[\s,.;!?。])"
),
),
# 한국어 이름: 역할/관계 명사 뒤에 붙은 인명 + 조사/호칭.
(
"NAME",
re.compile(
r"(?P<prefix>(?:내담자|상담자|학생|보호자|담임|교수|선생님|친구|엄마|아빠|어머니|아버지|동생|언니|오빠|형|누나)\s+)"
rf"(?P<value>{_KOREAN_FULL_NAME})"
rf"(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>\s*(?:님|씨|학생|상담자|내담자)?"
r"(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는|입니다|이에요|예요|이고|이고요))"
),
@ -96,7 +125,7 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>(?:은|는|이|가|을|를|와|과|에게|한테|라고|이라는))"
),
),
@ -104,21 +133,21 @@ _PII_PATTERNS: list[tuple[str, re.Pattern[str]]] = [
(
"NAME",
re.compile(
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME})"
rf"(?<![가-힣])(?P<value>{_KOREAN_FULL_NAME_BEFORE_SUFFIX})"
r"(?P<suffix>\s?(?:씨|님)(?:은|는|이|가|을|를|와|과|에게|한테|고|이고|인데)?)"
r"(?=$|[\s,.;!?。])"
),
),
# 주민등록번호 (6자리-7자리)
("RRN", re.compile(r"\b\d{6}[-\s]?\d{7}\b")),
("RRN", re.compile(r"(?<!\d)\d{6}[-\s]?\d{7}(?!\d)")),
# 휴대폰 (010-1234-5678 등)
("PHONE", re.compile(r"\b01[016789][-\s]?\d{3,4}[-\s]?\d{4}\b")),
("PHONE", re.compile(r"(?<!\d)01[016789][-\s]?\d{3,4}[-\s]?\d{4}(?!\d)")),
# 일반 전화
("PHONE", re.compile(r"\b0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}\b")),
("PHONE", re.compile(r"(?<!\d)0\d{1,2}[-\s]?\d{3,4}[-\s]?\d{4}(?!\d)")),
# 이메일
("EMAIL", re.compile(r"\b[\w.+-]+@[\w-]+\.[\w.-]+\b")),
# 카드/계좌 유사 긴 숫자열 (12자리 이상)
("NUMID", re.compile(r"\b\d{12,}\b")),
("NUMID", re.compile(r"(?<!\d)\d{12,}(?!\d)")),
# 구체적 날짜(생년월일 등): 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자리+ 또는 콤마구분) — 식별 맥락 보호

View file

@ -10,6 +10,16 @@ from . import guardrail
MaskFunc = Callable[[str], guardrail.MaskResult]
REPORT_SCHEMA_VERSION = "vignette.pii_masking_eval_report.v1"
INPUT_SCHEMA_VERSION = "vignette.pii_masking_eval_input.v1"
DEFAULT_CASE_META = {
"locale": "ko-KR",
"source": "synthetic",
"category": "unspecified",
"severity": "medium",
}
def load_cases(path: Path) -> list[dict[str, Any]]:
data = json.loads(path.read_text(encoding="utf-8"))
@ -18,9 +28,18 @@ def load_cases(path: Path) -> list[dict[str, Any]]:
return [dict(item) for item in data]
def evaluate_case(case: Mapping[str, Any], *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]:
def evaluate_case(
case: Mapping[str, Any],
*,
mask_func: MaskFunc = guardrail.mask_pii,
include_evidence_text: bool = False,
) -> dict[str, Any]:
case_id = str(case.get("id") or "")
text = str(case.get("text") or "")
locale = str(case.get("locale") or DEFAULT_CASE_META["locale"])
source = str(case.get("source") or DEFAULT_CASE_META["source"])
category = str(case.get("category") or DEFAULT_CASE_META["category"])
severity = str(case.get("severity") or DEFAULT_CASE_META["severity"])
result = mask_func(text)
entities = set(result.entities)
expected_entities = {str(item) for item in case.get("expected_entities") or []}
@ -34,25 +53,36 @@ def evaluate_case(case: Mapping[str, Any], *, mask_func: MaskFunc = guardrail.ma
required_missing = [item for item in required_substrings if item and item not in result.text_masked]
passed = not (missing_entities or unexpected_detected or forbidden_remaining or required_missing)
return {
report = {
"id": case_id,
"locale": locale,
"source": source,
"category": category,
"severity": severity,
"passed": passed,
"entities": sorted(entities),
"masked_text": result.text_masked,
"missing_entities": missing_entities,
"unexpected_entities": unexpected_detected,
"forbidden_remaining": forbidden_remaining,
"forbidden_remaining_count": len(forbidden_remaining),
"required_missing": required_missing,
}
if include_evidence_text:
report["masked_text"] = result.text_masked
report["forbidden_remaining"] = forbidden_remaining
return report
def evaluate_cases(
cases: Iterable[Mapping[str, Any]],
*,
mask_func: MaskFunc = guardrail.mask_pii,
include_evidence_text: bool = False,
) -> dict[str, Any]:
case_list = list(cases)
results = [evaluate_case(case, mask_func=mask_func) for case in case_list]
results = [
evaluate_case(case, mask_func=mask_func, include_evidence_text=include_evidence_text)
for case in case_list
]
total_expected_entities = 0
matched_expected_entities = 0
total_forbidden = 0
@ -64,11 +94,17 @@ def evaluate_cases(
total_expected_entities += len(expected_entities)
matched_expected_entities += len(expected_entities) - len(result["missing_entities"])
total_forbidden += len(forbidden)
removed_forbidden += len(forbidden) - len(result["forbidden_remaining"])
remaining_forbidden = int(result.get("forbidden_remaining_count", len(result.get("forbidden_remaining", []))))
removed_forbidden += len(forbidden) - remaining_forbidden
unexpected_violations += len(result["unexpected_entities"])
passed_cases = sum(1 for result in results if result["passed"])
return {
"schema_version": REPORT_SCHEMA_VERSION,
"input_schema_version": INPUT_SCHEMA_VERSION,
"run_mode": "technical_dry_run",
"data_source": "local_fixture",
"evidence_text_included": include_evidence_text,
"passed": passed_cases == len(results),
"cases_total": len(results),
"cases_passed": passed_cases,
@ -76,15 +112,45 @@ def evaluate_cases(
"expected_entity_recall": _ratio(matched_expected_entities, total_expected_entities),
"forbidden_substring_removal": _ratio(removed_forbidden, total_forbidden),
"unexpected_entity_violations": unexpected_violations,
"by_source": _breakdown(case_list, results, "source"),
"by_category": _breakdown(case_list, results, "category"),
"by_severity": _breakdown(case_list, results, "severity"),
"results": results,
}
def evaluate_fixture(path: Path, *, mask_func: MaskFunc = guardrail.mask_pii) -> dict[str, Any]:
return evaluate_cases(load_cases(path), mask_func=mask_func)
def evaluate_fixture(
path: Path,
*,
mask_func: MaskFunc = guardrail.mask_pii,
include_evidence_text: bool = False,
) -> dict[str, Any]:
return evaluate_cases(load_cases(path), mask_func=mask_func, include_evidence_text=include_evidence_text)
def _ratio(numerator: int, denominator: int) -> float:
if denominator <= 0:
return 1.0
return round(numerator / denominator, 4)
def _case_meta(case: Mapping[str, Any], key: str) -> str:
fallback = DEFAULT_CASE_META.get(key, "unspecified")
return str(case.get(key) or fallback)
def _breakdown(
cases: list[Mapping[str, Any]],
results: list[Mapping[str, Any]],
key: str,
) -> dict[str, dict[str, int]]:
grouped: dict[str, dict[str, int]] = {}
for case, result in zip(cases, results):
value = _case_meta(case, key)
bucket = grouped.setdefault(value, {"cases_total": 0, "cases_passed": 0, "cases_failed": 0})
bucket["cases_total"] += 1
if result.get("passed"):
bucket["cases_passed"] += 1
else:
bucket["cases_failed"] += 1
return dict(sorted(grouped.items()))

View file

@ -21,6 +21,9 @@ from .services import session_metrics
from .store import InProcSession, TurnRecord
StageLabel = Literal["라포", "탐색", "개입", "정리"]
WorksheetSpeaker = Literal["learner", "client"]
WorksheetItemSpec = tuple[str, str, list[str], WorksheetSpeaker | None]
WorksheetSectionSpec = tuple[str, str, list[WorksheetItemSpec]]
LEARNER_VISIBLE_AI_ROLE = "counselor"
_PHASE_KEY_BY_LABEL = {
@ -259,6 +262,68 @@ class ReviewCaseWorksheetSaveRequest(BaseModel):
limitations: list[str] = Field(default_factory=list)
CASE_WORKSHEET_SECTION_SPECS: list[WorksheetSectionSpec] = [
(
"exploration_11",
"탐색 11항목",
[
("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"),
("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", ""], "client"),
("emotion", "정서", ["불안", "우울", "", "슬프", "답답", "무섭", "외롭", "걱정"], "client"),
("cognition", "생각", ["생각", "느낌", "해야", "", "실패", "의미"], "client"),
("behavior", "행동", ["피하", "", "", "", "", "연락", "공부", ""], "client"),
("body", "신체·수면", ["", "식욕", "", "두통", "심장", "", "피곤"], "client"),
("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"),
("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"),
("risk", "위험 신호", ["", "자살", "해치", "사라지고", "끝내", "위험"], "client"),
("motivation", "변화동기", ["", "바라", "변화", "해보고", ""], None),
("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"),
],
),
(
"five_domains",
"호소 5영역",
[
("domain_emotion", "정서", ["불안", "우울", "", "슬프", "답답", "외롭"], "client"),
("domain_cognition", "인지", ["생각", "걱정", "실패", "", "의미"], "client"),
("domain_behavior", "행동", ["피하", "연락", "공부", "", ""], "client"),
("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"),
("domain_body", "신체", ["", "식욕", "", "두통", "피곤", ""], "client"),
],
),
(
"cognitive_triad_emotions",
"인지삼제·1/2차 감정",
[
("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"),
("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"),
("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"),
("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"),
("secondary_emotion", "2차 감정", ["", "짜증", "수치", "죄책", "부끄"], "client"),
],
),
(
"protective_barrier_quadrants",
"보호·방해 4사분면",
[
("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "", "견뎠"], None),
("internal_barrier", "내적 방해요인", ["", "두려", "불안", "회피", "걱정"], "client"),
("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"),
("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"),
],
),
(
"biopsychosocial_goals",
"생물·심리·사회 목표",
[
("bio_goal", "생물", ["", "식사", "운동", "", "피곤"], "client"),
("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None),
("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None),
],
),
]
class SessionTeacherReviewStatus(BaseModel):
status: Literal["pending", "viewed", "closed"] = "pending"
note: str = ""
@ -824,7 +889,7 @@ def _worksheet_item(
def _worksheet_section(
key: str,
title: str,
specs: list[tuple[str, str, list[str], Literal["learner", "client"] | None]],
specs: list[WorksheetItemSpec],
turns: list[ReviewTurn],
fallback_client: ReviewTurn | None,
fallback_learner: ReviewTurn | None,
@ -845,6 +910,13 @@ def _worksheet_section(
return ReviewWorksheetSection(key=key, title=title, items=items)
def case_worksheet_template_item_keys() -> dict[str, set[str]]:
return {
section_key: {item_key for item_key, _, _, _ in item_specs}
for section_key, _, item_specs in CASE_WORKSHEET_SECTION_SPECS
}
def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
if not turns:
return ReviewCaseWorksheet(
@ -855,68 +927,6 @@ def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
fallback_client = next((turn for turn in turns if turn.speaker == "client"), None)
fallback_learner = next((turn for turn in turns if turn.speaker == "learner"), None)
section_specs: list[
tuple[str, str, list[tuple[str, str, list[str], Literal["learner", "client"] | None]]]
] = [
(
"exploration_11",
"탐색 11항목",
[
("presenting_complaint", "주호소", ["힘들", "문제", "걱정", "불안", "우울", "스트레스", "관계"], "client"),
("trigger_context", "계기·상황", ["언제", "상황", "최근", "계기", ""], "client"),
("emotion", "정서", ["불안", "우울", "", "슬프", "답답", "무섭", "외롭", "걱정"], "client"),
("cognition", "생각", ["생각", "느낌", "해야", "", "실패", "의미"], "client"),
("behavior", "행동", ["피하", "", "", "", "", "연락", "공부", ""], "client"),
("body", "신체·수면", ["", "식욕", "", "두통", "심장", "", "피곤"], "client"),
("relationship", "관계", ["친구", "가족", "부모", "엄마", "아빠", "교수", "사람", "관계"], "client"),
("resources", "자원", ["도움", "지지", "친구", "상담", "선생님", "가족"], "client"),
("risk", "위험 신호", ["", "자살", "해치", "사라지고", "끝내", "위험"], "client"),
("motivation", "변화동기", ["", "바라", "변화", "해보고", ""], None),
("first_goal", "상담 목표 초안", ["목표", "계획", "다음", "해볼", "원하"], "learner"),
],
),
(
"five_domains",
"호소 5영역",
[
("domain_emotion", "정서", ["불안", "우울", "", "슬프", "답답", "외롭"], "client"),
("domain_cognition", "인지", ["생각", "걱정", "실패", "", "의미"], "client"),
("domain_behavior", "행동", ["피하", "연락", "공부", "", ""], "client"),
("domain_relationship", "대인관계", ["친구", "가족", "사람", "관계", "부모"], "client"),
("domain_body", "신체", ["", "식욕", "", "두통", "피곤", ""], "client"),
],
),
(
"cognitive_triad_emotions",
"인지삼제·1/2차 감정",
[
("triad_self", "자기", ["나는", "내가", "나 자신", "스스로"], "client"),
("triad_world", "타인·세계", ["사람", "세상", "학교", "가족", "친구"], "client"),
("triad_future", "미래", ["앞으로", "미래", "계속", "나중"], "client"),
("primary_emotion", "1차 감정", ["불안", "슬프", "무섭", "외롭", "걱정"], "client"),
("secondary_emotion", "2차 감정", ["", "짜증", "수치", "죄책", "부끄"], "client"),
],
),
(
"protective_barrier_quadrants",
"보호·방해 4사분면",
[
("internal_protective", "내적 보호요인", ["해보고", "버텼", "노력", "", "견뎠"], None),
("internal_barrier", "내적 방해요인", ["", "두려", "불안", "회피", "걱정"], "client"),
("external_protective", "외적 보호요인", ["친구", "가족", "상담", "교수", "도움"], "client"),
("external_barrier", "외적 방해요인", ["갈등", "압박", "비난", "스트레스", "혼자"], "client"),
],
),
(
"biopsychosocial_goals",
"생물·심리·사회 목표",
[
("bio_goal", "생물", ["", "식사", "운동", "", "피곤"], "client"),
("psy_goal", "심리", ["생각", "감정", "불안", "연습", "조절"], None),
("social_goal", "사회", ["관계", "대화", "연락", "도움", "친구"], None),
],
),
]
sections = [
_worksheet_section(
@ -927,7 +937,7 @@ def case_worksheet_from_turns(turns: list[ReviewTurn]) -> ReviewCaseWorksheet:
fallback_client,
fallback_learner,
)
for key, title, specs in section_specs
for key, title, specs in CASE_WORKSHEET_SECTION_SPECS
]
return ReviewCaseWorksheet(
status="draft_from_transcript",

View file

@ -0,0 +1,93 @@
import copy
import json
import subprocess
import sys
import unittest
from pathlib import Path
try:
from jsonschema import Draft202012Validator
except ModuleNotFoundError: # pragma: no cover - optional test helper dependency
Draft202012Validator = None
from app.services.case_worksheet_rubric import load_rubric, validate_rubric
from app.session_read_model import case_worksheet_template_item_keys
REPO_ROOT = Path(__file__).resolve().parents[3]
RUBRIC_PATH = REPO_ROOT / "data" / "rubrics" / "case-worksheet-rubric.json"
SCHEMA_PATH = REPO_ROOT / "data" / "rubrics" / "case-worksheet-rubric.schema.json"
SCRIPT_PATH = REPO_ROOT / "scripts" / "check-case-worksheet-rubric.py"
class CaseWorksheetRubricTests(unittest.TestCase):
def test_scaffold_matches_generated_worksheet_keys_without_enabling_scoring(self) -> None:
rubric = load_rubric(RUBRIC_PATH)
report = validate_rubric(rubric, expected_item_keys=case_worksheet_template_item_keys())
self.assertTrue(report["passed"], report)
self.assertEqual(report["schema_version"], "vignette.case_worksheet_rubric.v1")
self.assertEqual(report["status"], "scaffold_only")
self.assertFalse(report["scoring_enabled"])
self.assertEqual(report["sections_total"], 5)
self.assertEqual(report["items_total"], 28)
self.assertGreaterEqual(len(report["warnings"]), 20)
self._validate_with_schema(rubric, SCHEMA_PATH)
def test_scoring_requires_clinical_approval(self) -> None:
rubric = load_rubric(RUBRIC_PATH)
draft = copy.deepcopy(rubric)
draft["status"] = "draft"
draft["scoring_enabled"] = True
report = validate_rubric(draft, expected_item_keys=case_worksheet_template_item_keys())
self.assertFalse(report["passed"])
self.assertIn("scoring_enabled requires status=approved", report["errors"])
def test_missing_worksheet_item_fails_validation(self) -> None:
rubric = load_rubric(RUBRIC_PATH)
broken = copy.deepcopy(rubric)
broken["sections"][0]["items"] = broken["sections"][0]["items"][1:]
report = validate_rubric(broken, expected_item_keys=case_worksheet_template_item_keys())
self.assertFalse(report["passed"])
self.assertIn("exploration_11: missing worksheet item: presenting_complaint", report["errors"])
def test_cli_reports_json(self) -> None:
completed = subprocess.run(
[
sys.executable,
"-X",
"utf8",
str(SCRIPT_PATH),
"--rubric",
str(RUBRIC_PATH),
"--json",
],
cwd=str(REPO_ROOT),
check=True,
capture_output=True,
text=True,
encoding="utf-8",
)
report = json.loads(completed.stdout)
self.assertTrue(report["passed"], report)
self.assertEqual(report["items_total"], 28)
self.assertFalse(report["scoring_enabled"])
self.assertEqual(report["rubric_path"], "data/rubrics/case-worksheet-rubric.json")
self.assertRegex(report["content_sha256"], r"^[a-f0-9]{64}$")
def _validate_with_schema(self, instance: object, schema_path: Path) -> None:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
self.assertEqual(schema.get("$schema"), "https://json-schema.org/draft/2020-12/schema")
if Draft202012Validator is None:
return
Draft202012Validator.check_schema(schema)
Draft202012Validator(schema).validate(instance)
if __name__ == "__main__":
unittest.main()

View file

@ -5,14 +5,46 @@ import unittest
from pathlib import Path
from unittest.mock import patch
try:
from jsonschema import Draft202012Validator
except ModuleNotFoundError: # pragma: no cover - optional test helper dependency
Draft202012Validator = None
from app.services import guardrail
from app.services.pii_masking_eval import evaluate_fixture, load_cases
REPO_ROOT = Path(__file__).resolve().parents[3]
FIXTURE_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-ko-fixtures.json"
INPUT_SCHEMA_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-eval-input.schema.json"
REPORT_SCHEMA_PATH = REPO_ROOT / "data" / "privacy" / "pii-masking-eval-report.schema.json"
SCRIPT_PATH = REPO_ROOT / "scripts" / "evaluate-pii-masking.py"
EXPECTED_CATEGORIES = {
"contact",
"name",
"national_id",
"negative_control",
"organization",
"quasi_identifier",
}
RAW_IDENTIFIERS = (
"김서연",
"박민수",
"최하늘",
"한신대학교",
"상담심리학과",
"마음봄상담센터",
"새봄병원",
"010-1234-5678",
"seoyeon@example.com",
"990101-1234567",
"123456789012",
"2001년 4월 18일",
"서울시 강남구 역삼동",
"1200원",
)
class PiiMaskingEvalTests(unittest.TestCase):
def setUp(self) -> None:
@ -27,22 +59,57 @@ class PiiMaskingEvalTests(unittest.TestCase):
def test_fixture_cases_are_valid_json_list(self) -> None:
cases = load_cases(FIXTURE_PATH)
self.assertGreaterEqual(len(cases), 5)
self.assertEqual(len(cases), 15)
self.assertTrue(all(case.get("id") for case in cases))
self.assertTrue(all(case.get("text") for case in cases))
self.assertTrue(all(case.get("locale") == "ko-KR" for case in cases))
self.assertTrue(all(case.get("source") == "synthetic" for case in cases))
self.assertTrue(all("expected_entities" in case for case in cases))
self.assertTrue(all("forbidden_substrings" in case for case in cases))
self.assertEqual({case["category"] for case in cases}, EXPECTED_CATEGORIES)
self._validate_with_schema(cases, INPUT_SCHEMA_PATH)
def test_ko_name_org_fixture_passes_without_raw_identifier_leak(self) -> None:
report = evaluate_fixture(FIXTURE_PATH)
self.assertEqual(report["schema_version"], "vignette.pii_masking_eval_report.v1")
self.assertEqual(report["input_schema_version"], "vignette.pii_masking_eval_input.v1")
self.assertEqual(report["run_mode"], "technical_dry_run")
self.assertEqual(report["data_source"], "local_fixture")
self.assertFalse(report["evidence_text_included"])
self.assertTrue(report["passed"], report)
self.assertEqual(report["cases_total"], 15)
self.assertEqual(report["cases_passed"], 15)
self.assertEqual(report["cases_failed"], 0)
self.assertEqual(report["expected_entity_recall"], 1.0)
self.assertEqual(report["forbidden_substring_removal"], 1.0)
self.assertEqual(report["unexpected_entity_violations"], 0)
self.assertEqual(report["by_source"]["synthetic"]["cases_passed"], 15)
self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES)
self.assertEqual(report["by_severity"]["critical"]["cases_passed"], 2)
for result in report["results"]:
self.assertNotIn("masked_text", result)
self.assertNotIn("forbidden_remaining", result)
self.assertIn("forbidden_remaining_count", result)
self._validate_with_schema(report, REPORT_SCHEMA_PATH)
blob = json.dumps(report, ensure_ascii=False)
for raw in ("김서연", "박민수", "한신대학교", "상담심리학과", "마음봄상담센터"):
for raw in RAW_IDENTIFIERS:
self.assertNotIn(raw, blob)
def test_natural_language_name_contexts_are_masked_without_label_false_positive(self) -> None:
cases = {
"제 이름은 김서연입니다.": "제 이름은 [NAME]입니다.",
"보호자 이름은 박민수입니다.": "보호자 이름은 [NAME]입니다.",
"저는 최하늘입니다.": "저는 [NAME]입니다.",
"김서연입니다.": "[NAME]입니다.",
"이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.": "이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.",
}
for raw, expected in cases.items():
with self.subTest(raw=raw):
result = guardrail.mask_pii(raw)
self.assertEqual(result.text_masked, expected)
def test_cli_reports_json_and_nonzero_gate_shape(self) -> None:
completed = subprocess.run(
[
@ -63,7 +130,19 @@ class PiiMaskingEvalTests(unittest.TestCase):
report = json.loads(completed.stdout)
self.assertTrue(report["passed"])
self.assertEqual(report["cases_total"], 5)
self.assertEqual(report["cases_total"], 15)
self.assertFalse(report["evidence_text_included"])
self.assertEqual(set(report["by_category"]), EXPECTED_CATEGORIES)
self.assertTrue(all("masked_text" not in result for result in report["results"]))
self._validate_with_schema(report, REPORT_SCHEMA_PATH)
def _validate_with_schema(self, instance: object, schema_path: Path) -> None:
schema = json.loads(schema_path.read_text(encoding="utf-8"))
self.assertEqual(schema.get("$schema"), "https://json-schema.org/draft/2020-12/schema")
if Draft202012Validator is None:
return
Draft202012Validator.check_schema(schema)
Draft202012Validator(schema).validate(instance)
if __name__ == "__main__":

View file

@ -376,6 +376,10 @@ test.describe("layout visual gate @single-run", () => {
await gateScreen(page, "persona-studio", async () => {
await expect(page.locator(".ps-layout")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("내담자 설계·검수 작업면")).toBeVisible();
await page.getByRole("tab", { name: "프롬프트" }).click();
const promptPreview = page.getByLabel("프롬프트 미리보기");
await expect(promptPreview).toBeVisible();
await expect(promptPreview.getByRole("textbox")).toHaveCount(0);
});
});

View file

@ -329,6 +329,108 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("saves persona studio list rows as structured arrays @single-run", async ({ page }) => {
type PersonaDraftSavePayload = {
ccd: { automatic_thought: string[] };
code: string;
difficulty: string;
display_name: string;
is_synthetic: boolean;
source_provenance: unknown;
theory_target: string;
triggers: { forbidden: string[]; sore_spots: string[] };
};
let savedPayload: PersonaDraftSavePayload | null = null;
await signInAsTeacher(page);
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
});
await page.route("**/api/personas/review", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
});
await page.route("**/api/personas/drafts", async (route) => {
if (route.request().method() !== "POST") return route.fallback();
savedPayload = route.request().postDataJSON() as PersonaDraftSavePayload;
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify({
persona_id: "00000000-0000-0000-0000-000000000703",
code: savedPayload.code,
version: 1,
status: "draft",
display_name: savedPayload.display_name,
difficulty: savedPayload.difficulty,
theory_target: savedPayload.theory_target,
source_provenance: savedPayload.source_provenance,
is_synthetic: savedPayload.is_synthetic,
created_at: "2026-06-28T00:00:00Z",
approved_at: null,
}),
});
});
await page.goto("/teach/personas");
await page.getByLabel("표시 이름").fill("항목형 페르소나");
await page.getByRole("tab", { name: "임상" }).click();
const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" });
await automaticThoughts.getByRole("textbox", { name: "자동사고 1", exact: true }).fill("삭제될 자동사고");
await automaticThoughts.getByRole("button", { name: "항목 추가" }).click();
await automaticThoughts.getByRole("textbox", { name: "자동사고 2", exact: true }).fill("남길 자동사고");
await automaticThoughts.getByRole("button", { name: "자동사고 1 삭제" }).click();
await page.getByRole("tab", { name: "안전" }).click();
const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" });
await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정");
await page.getByRole("button", { name: "초안 저장" }).click();
await expect(page.getByText("P1 v1 초안을 저장했습니다.")).toBeVisible();
expect(savedPayload).toBeTruthy();
expect(savedPayload?.ccd.automatic_thought).toEqual(["남길 자동사고"]);
expect(savedPayload?.triggers.forbidden).toEqual(["네가 예민한 거라고 단정"]);
expect(savedPayload?.triggers.sore_spots).toEqual([]);
await expectNoHorizontalOverflow(page);
});
test("renders persona prompt preview as labeled sections without raw JSON @single-run", async ({ page }) => {
await signInAsTeacher(page);
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
});
await page.route("**/api/personas/review", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) });
});
await page.goto("/teach/personas");
await page.getByLabel("표시 이름").fill("프롬프트 검토 페르소나");
await page.getByRole("tab", { name: "임상" }).click();
const automaticThoughts = page.locator(".ps-list-field").filter({ hasText: "자동사고" });
await automaticThoughts.getByRole("textbox", { name: "자동사고 1", exact: true }).fill("말하면 더 이상하게 볼 거야");
await page.getByRole("tab", { name: "안전" }).click();
const forbidden = page.locator(".ps-list-field").filter({ hasText: "상담자 금기" });
await forbidden.getByRole("textbox", { name: "상담자 금기 1", exact: true }).fill("네가 예민한 거라고 단정");
await page.getByRole("tab", { name: "프롬프트" }).click();
const promptPreview = page.getByLabel("프롬프트 미리보기");
await expect(promptPreview.getByRole("heading", { name: /L1 페르소나 카드/ })).toBeVisible();
await expect(promptPreview.getByText("자동사고")).toBeVisible();
await expect(promptPreview.getByText("말하면 더 이상하게 볼 거야")).toBeVisible();
await expect(promptPreview.getByText("상담자 금기")).toBeVisible();
await expect(promptPreview.getByText("네가 예민한 거라고 단정")).toBeVisible();
await expect(promptPreview.getByRole("textbox")).toHaveCount(0);
await expect(promptPreview).not.toContainText('"automatic_thought"');
await expect(promptPreview).not.toContainText('"forbidden"');
await expect(promptPreview).not.toContainText("{");
await expectNoHorizontalOverflow(page);
});
test("archives an approved persona from persona studio without layout drift @single-run", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const personaId = "00000000-0000-0000-0000-000000000702";

View file

@ -10,6 +10,8 @@
/>
<meta name="robots" content="index,follow,max-snippet:160,max-image-preview:large" />
<link rel="canonical" href="https://vignette.chanpaca.net/" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="shortcut icon" href="/favicon.svg" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:site_name" content="Vignette" />

View file

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Vignette">
<rect width="64" height="64" rx="14" fill="#f6f1e8"/>
<path d="M14 14h36L35.7 50H27L14 14Z" fill="#2f6f73"/>
<path d="M24.2 20h14.9l-7.2 19.1L24.2 20Z" fill="#c46d4a"/>
</svg>

After

Width:  |  Height:  |  Size: 276 B

View file

@ -36,6 +36,7 @@ import { Mouth } from "./Mouth";
import { live2dModel3Path, live2dModelForPersonaCode, live2dMotionForExpression } from "./live2dModel";
import { useExpressionTransition } from "./useExpressionTransition";
import { RasterBust } from "./RasterBust";
import "./client-avatar.css";
/* ( import )
Session.tsx ClientAvatar . */
@ -82,7 +83,6 @@ function prefersReducedMotion(): boolean {
window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true
);
}
/** prefers-reduced-motion 을 반응형으로 구독 */
function useReducedMotion(): boolean {
const [reduced, setReduced] = useState<boolean>(prefersReducedMotion);
@ -299,7 +299,6 @@ export function ClientAvatar({
data-realism={realism} /* 사실성은 데이터로만 유지(시각 표식 비노출, §4.6) */
aria-label={`교육용 가상 내담자: ${persona.label}, ${STATE_TEXT[state]}, ${expressionLabel}`}
>
<style>{AVATAR_CSS}</style>
{/* 상단 라벨 — 실존 인물 오인 차단(상시, §4.1) */}
{showCaption ? (
@ -399,60 +398,3 @@ export function ClientAvatar({
</figure>
);
}
const AVATAR_CSS = `
.vg-avatar{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);margin:0;}
.vg-avatar__label{
display:inline-flex;align-items:center;gap:7px;
font-family:var(--font-num);font-size:var(--fs-xs);font-weight:600;letter-spacing:0.06em;
color:var(--text-muted);text-transform:none;
}
.vg-avatar__label-dot{width:6px;height:6px;border-radius:50%;background:var(--clay);flex:none;}
.vg-avatar__stage{
position:relative;width:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;overflow:hidden;
background:
radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%);
box-shadow:
inset 0 0 0 1px rgba(251,250,248,.28),
inset 0 -24px 40px rgba(28,42,42,.13);
}
.vg-avatar__aura{
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
animation:vgAuraBreathe 6s var(--ease-in-out) infinite;
}
.vg-avatar__aura.is-reduced{animation:none;}
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);}
/* ── 래스터(Live2D식) 렌더: 레이어 합성 흉상 ── */
.vg-raster{position:absolute;inset:0;z-index:1;pointer-events:none;will-change:transform;}
.vg-raster__layer{
position:absolute;left:50%;top:var(--vg-raster-top,-15%);
height:var(--vg-raster-h,136%);width:auto;max-width:none;
transform:translateX(-50%);object-fit:contain;
user-select:none;-webkit-user-drag:none;opacity:0;
}
.vg-raster__layer--shoulders{opacity:1;z-index:1;}
.vg-raster__layer--neck{opacity:1;z-index:2;}
.vg-raster__layer--hairback{opacity:1;z-index:3;}
.vg-raster__layer--ear{opacity:1;z-index:4;}
.vg-raster__layer--face{opacity:1;z-index:5;}
.vg-raster__layer--forehead{opacity:1;z-index:6;}
.vg-raster__layer--hair{opacity:1;z-index:7;will-change:transform;}
.vg-raster__layer--bangs{opacity:1;z-index:8;will-change:transform;}
.vg-raster__layer--brow{z-index:9;will-change:opacity,transform;}
.vg-raster__layer--eyes{z-index:10;}
.vg-raster__layer--eyelid{z-index:11;}
.vg-raster__layer--nose{opacity:1;z-index:12;}
.vg-raster__layer--mouth{z-index:13;}
.vg-raster__layer--mouthopen{z-index:14;}
.vg-raster__layer--base{opacity:1;z-index:1;}
.vg-raster__layer--upperface{z-index:2;}
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}
.vg-avatar__state b{font-weight:600;color:var(--text-body);}
@media (prefers-reduced-motion: reduce){
.vg-avatar__aura{animation:none;}
}
`;

View file

@ -0,0 +1,54 @@
.vg-avatar{display:flex;flex-direction:column;align-items:center;gap:var(--sp-3);margin:0;}
.vg-avatar__label{
display:inline-flex;align-items:center;gap:7px;
font-family:var(--font-num);font-size:var(--fs-xs);font-weight:600;letter-spacing:0.06em;
color:var(--text-muted);text-transform:none;
}
.vg-avatar__label-dot{width:6px;height:6px;border-radius:50%;background:var(--clay);flex:none;}
.vg-avatar__stage{
position:relative;width:100%;border-radius:50%;
display:flex;align-items:center;justify-content:center;overflow:hidden;
background:
radial-gradient(circle at 50% 38%, rgba(251,250,248,.98) 0%, rgba(246,240,236,.9) 42%, rgba(238,244,242,.76) 64%, rgba(145,200,189,.22) 84%, rgba(30,39,36,.18) 100%);
box-shadow:
inset 0 0 0 1px rgba(251,250,248,.28),
inset 0 -24px 40px rgba(28,42,42,.13);
}
.vg-avatar__aura{
position:absolute;inset:-12%;border-radius:50%;pointer-events:none;
animation:vgAuraBreathe 6s var(--ease-in-out) infinite;
}
.vg-avatar__aura.is-reduced{animation:none;}
@keyframes vgAuraBreathe{0%,100%{opacity:.85;transform:scale(1)}50%{opacity:1;transform:scale(1.04)}}
.vg-avatar__svg{position:relative;z-index:1;display:block;transition:opacity var(--dur-base) var(--ease-out);}
/* ── 래스터(Live2D식) 렌더: 레이어 합성 흉상 ── */
.vg-raster{position:absolute;inset:0;z-index:1;pointer-events:none;will-change:transform;}
.vg-raster__layer{
position:absolute;left:50%;top:var(--vg-raster-top,-15%);
height:var(--vg-raster-h,136%);width:auto;max-width:none;
transform:translateX(-50%);object-fit:contain;
user-select:none;-webkit-user-drag:none;opacity:0;
}
.vg-raster__layer--shoulders{opacity:1;z-index:1;}
.vg-raster__layer--neck{opacity:1;z-index:2;}
.vg-raster__layer--hairback{opacity:1;z-index:3;}
.vg-raster__layer--ear{opacity:1;z-index:4;}
.vg-raster__layer--face{opacity:1;z-index:5;}
.vg-raster__layer--forehead{opacity:1;z-index:6;}
.vg-raster__layer--hair{opacity:1;z-index:7;will-change:transform;}
.vg-raster__layer--bangs{opacity:1;z-index:8;will-change:transform;}
.vg-raster__layer--brow{z-index:9;will-change:opacity,transform;}
.vg-raster__layer--eyes{z-index:10;}
.vg-raster__layer--eyelid{z-index:11;}
.vg-raster__layer--nose{opacity:1;z-index:12;}
.vg-raster__layer--mouth{z-index:13;}
.vg-raster__layer--mouthopen{z-index:14;}
.vg-raster__layer--base{opacity:1;z-index:1;}
.vg-raster__layer--upperface{z-index:2;}
.vg-avatar__meta{display:flex;flex-direction:column;align-items:center;gap:3px;text-align:center;}
.vg-avatar__persona{font-size:var(--fs-sm);font-weight:600;color:var(--text-strong);}
.vg-avatar__state{font-size:var(--fs-xs);color:var(--text-muted);}
.vg-avatar__state b{font-weight:600;color:var(--text-body);}
@media (prefers-reduced-motion: reduce){
.vg-avatar__aura{animation:none;}
}

View file

@ -18,6 +18,7 @@ export type IconName =
| "chevron-right"
| "chevron-left"
| "check"
| "plus"
| "share"
| "mic"
| "mic-off"
@ -94,6 +95,12 @@ const PATHS: Record<IconName, ReactNode> = {
"chevron-right": <polyline points="9 6 15 12 9 18" />,
"chevron-left": <polyline points="15 6 9 12 15 18" />,
check: <polyline points="20 6 9 17 4 12" />,
plus: (
<>
<line x1="12" y1="5" x2="12" y2="19" />
<line x1="5" y1="12" x2="19" y2="12" />
</>
),
share: (
<>
<circle cx="18" cy="5" r="3" />

File diff suppressed because it is too large Load diff

View file

@ -1,8 +1,10 @@
import { useEffect, useState } from "react";
import { useLocation, useNavigate } from "react-router-dom";
import { Icon } from "../components/ui/Icon";
import { roleHomePath, useAuth, type Role } from "../lib/auth";
import { apiUrl, authApi, type AuthConfigResponse } from "../lib/api";
import { LoginBrand } from "./login/LoginBrand";
import { LoginPanel, type LoginRoleOption } from "./login/LoginPanel";
import "./login/login.css";
const OAUTH_NOT_CONFIGURED_MESSAGE =
"Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요.";
@ -57,7 +59,7 @@ function oauthMessage(reason: string | null): string | null {
return OAUTH_FAILED_MESSAGE;
}
const ROLE_OPTIONS: { role: Role; label: string; desc: string; dotClass: string }[] = [
const ROLE_OPTIONS: LoginRoleOption[] = [
{ role: "learner", label: "학습자", desc: "연습 공간으로 이동", dotClass: "learner" },
{ role: "teacher", label: "교수자", desc: "담당 학습자 관리", dotClass: "teacher" },
{ role: "admin", label: "관리자", desc: "운영 설정과 감사", dotClass: "admin" },
@ -143,6 +145,11 @@ export default function Login() {
: allowedDomains[0]
? "승인된 Google 계정"
: "관리자 설정 필요";
const oauthStatusMessage = devOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: oauthChecking
? "Google 로그인 설정을 확인하는 중입니다."
: "Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요.";
const startOAuth = () => {
if (!oauthReady) {
@ -182,485 +189,28 @@ export default function Login() {
return (
<main className="lg-root">
<style>{LOGIN_CSS}</style>
<section className="lg-brand" aria-label="Vignette">
<div className="lg-wordmark">
<span className="lg-mark" aria-hidden="true">
<svg viewBox="0 0 26 26" width={26} height={26} fill="none">
<circle cx="13" cy="13" r="11" stroke="currentColor" strokeWidth="2" />
<path
d="M8 14.5c1.4 1.7 3 2.5 5 2.5s3.6-.8 5-2.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="13" cy="9" r="1.6" fill="var(--clay)" />
</svg>
</span>
<span>Vignette</span>
</div>
<div className="lg-copy">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
</span>
<h1><span className="lg-highlight"> </span>, <br/> .</h1>
<p>
Vignette는 , , .
.
</p>
</div>
<div className="lg-policy">
<span>
<Icon name="shield" size={17} />
</span>
{allowedDomains.length ? (
allowedDomains.map((domain) => <b key={domain}>{domain}</b>)
) : (
<b>{oauthChecking ? "확인 중" : "설정 필요"}</b>
)}
</div>
</section>
<section className="lg-enter" aria-label="로그인">
<div className="lg-panel">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
</span>
<h2></h2>
<p className="lg-lead">
Google .
</p>
<div className="lg-actions">
<button
className="lg-obtn primary"
type="button"
onClick={startOAuth}
disabled={!oauthReady}
>
<span className="ic">
<Icon name="school" size={19} strokeWidth={1.8} />
</span>
<span className="txt">
Google
<span className="sub">{primaryDomainLabel}</span>
</span>
<Icon name="chevron-right" size={18} strokeWidth={2} />
</button>
<button
className="lg-obtn secondary"
type="button"
onClick={startOAuth}
disabled={!oauthReady}
>
<span className="ic">
<Icon name="google" size={19} />
</span>
<span className="txt">
Google
<span className="sub">{secondaryDomainLabel}</span>
</span>
<Icon name="chevron-right" size={18} strokeWidth={2} />
</button>
</div>
{!oauthReady ? (
<div className="lg-config" role="status">
<Icon name={authConfigError ? "alert" : "info"} size={17} />
<span>
{devOAuthUnavailable
? LOCAL_OAUTH_UNAVAILABLE_MESSAGE
: oauthChecking
? "Google 로그인 설정을 확인하는 중입니다."
: "Google 로그인이 아직 연결되지 않았습니다. 관리자에게 OAuth 클라이언트 설정을 요청하세요."}
</span>
</div>
) : null}
{devLoginReady ? (
<div className="lg-dev">
<div className="lg-devhead">
<span> </span>
<small> </small>
</div>
<div className="lg-rolepick" role="radiogroup" aria-label="로컬 테스트 역할">
{ROLE_OPTIONS.map((opt) => (
<button
key={opt.role}
type="button"
role="radio"
aria-checked={selected === opt.role}
className={`lg-roleopt ${selected === opt.role ? "is-sel" : ""}`}
onClick={() => setSelected(opt.role)}
>
<span className={`d ${opt.dotClass}`} aria-hidden="true" />
<span>
<b>{opt.label}</b>
<small>{opt.desc}</small>
</span>
{selected === opt.role ? <Icon name="check" size={15} /> : null}
</button>
))}
</div>
<button
className="lg-devbtn"
type="button"
onClick={() => void enterDev(selected)}
disabled={pending}
>
{pending ? "테스트 계정 확인 중" : "로컬 테스트 계정으로 계속"}
</button>
{loginError ? (
<p className="lg-error">
{loginError}
{loginErrorReason ? <small> : {loginErrorReason}</small> : null}
</p>
) : null}
</div>
) : null}
{!devLoginReady && loginError ? (
<p className="lg-error">
{loginError}
{loginErrorReason ? <small> : {loginErrorReason}</small> : null}
</p>
) : null}
<p className="lg-note">
. , , .
</p>
</div>
</section>
<LoginBrand allowedDomains={allowedDomains} oauthChecking={oauthChecking} />
<LoginPanel
oauth={{
ready: oauthReady,
primaryDomainLabel,
secondaryDomainLabel,
statusIcon: authConfigError ? "alert" : "info",
statusMessage: oauthStatusMessage,
onStart: startOAuth,
}}
devAccess={{
ready: devLoginReady,
selected,
pending,
options: ROLE_OPTIONS,
onSelect: setSelected,
onEnter: (role) => {
void enterDev(role);
},
}}
error={{ message: loginError, reason: loginErrorReason }}
/>
</main>
);
}
const LOGIN_CSS = `
.lg-root{
min-height:100dvh;
position:relative;
display:grid;
grid-template-columns:minmax(0,1fr) minmax(360px,500px);
background:
linear-gradient(90deg,rgba(7,16,14,.84) 0%,rgba(10,21,19,.72) 48%,rgba(10,21,19,.55) 74%,rgba(10,21,19,.62) 100%),
var(--asset-login-room) center / cover no-repeat;
color:#edf4f2;
overflow-x:hidden;
overflow-y:auto;
}
.lg-root::before{
content:"";
position:absolute;
inset:0;
background:linear-gradient(180deg,rgba(3,9,8,.18),rgba(3,9,8,.42));
pointer-events:none;
}
.lg-brand,
.lg-enter{
position:relative;
z-index:1;
}
.lg-brand{
min-width:0;
width:100%;
position:relative;
display:flex;
flex-direction:column;
justify-content:space-between;
gap:var(--sp-7);
padding:var(--sp-7);
background:transparent;
color:#edf4f2;
overflow:hidden;
}
.lg-brand::after{
display:none;
}
.lg-wordmark{
position:relative;
z-index:1;
display:flex;
align-items:center;
gap:10px;
font-size:19px;
font-weight:700;
letter-spacing:0;
color:#edf4f2;
}
.lg-mark{display:grid;place-items:center;color:var(--accent-bright);}
.lg-copy{max-width:620px;}
.lg-copy,
.lg-policy{
position:relative;
z-index:1;
}
.lg-kicker{
display:inline-flex;
align-items:center;
gap:8px;
font-family:var(--font-num);
font-size:12px;
font-weight:700;
letter-spacing:0;
text-transform:uppercase;
color:var(--accent-bright);
}
.lg-kicker .d{width:6px;height:6px;border-radius:50%;background:currentColor;}
.lg-highlight{
display:inline-block;
position:relative;
z-index:0;
padding:0 4px;
background:linear-gradient(90deg,#59b5a6,#8ee4d6);
-webkit-background-clip:text;
background-clip:text;
-webkit-text-fill-color:transparent;
color:transparent;
}
.lg-copy h1{
margin:var(--sp-4) 0 0;
max-width:640px;
font-size:56px;
line-height:1.12;
letter-spacing:0;
font-weight:760;
}
.lg-copy p{
margin:var(--sp-5) 0 0;
max-width:560px;
color:rgba(237,244,242,.72);
font-size:17px;
line-height:1.75;
}
.lg-policy{
display:flex;
align-items:center;
flex-wrap:wrap;
gap:10px;
width:max-content;
max-width:min(430px,100%);
padding:14px 16px;
border:1px solid rgba(255,255,255,.14);
border-radius:var(--radius-lg);
background:rgba(237,244,242,.08);
backdrop-filter:blur(18px) saturate(1.08);
-webkit-backdrop-filter:blur(18px) saturate(1.08);
color:rgba(237,244,242,.66);
font-size:13px;
}
.lg-policy span,.lg-policy b{
display:inline-flex;
align-items:center;
gap:7px;
}
.lg-policy b{
color:#edf4f2;
background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.12);
border-radius:999px;
padding:5px 10px;
font-weight:650;
}
.lg-enter{
min-width:0;
display:flex;
align-items:center;
justify-content:center;
padding:var(--sp-6);
background:transparent;
}
.lg-panel{
width:100%;
max-width:430px;
background:rgba(11,25,22,.64);
border:1px solid rgba(255,255,255,.16);
border-radius:var(--radius-lg);
box-shadow:0 24px 70px rgba(0,0,0,.34), inset 0 1px 0 rgba(255,255,255,.08);
backdrop-filter:blur(24px) saturate(1.14);
-webkit-backdrop-filter:blur(24px) saturate(1.14);
padding:var(--sp-6);
color:#edf4f2;
}
.lg-panel h2{
margin:var(--sp-3) 0 0;
font-size:28px;
line-height:1.25;
letter-spacing:0;
color:#f7fbf9;
}
.lg-lead{
margin:10px 0 0;
color:rgba(237,244,242,.76);
font-size:14px;
line-height:1.65;
}
.lg-actions{display:flex;flex-direction:column;gap:var(--sp-3);margin-top:var(--sp-6);}
.lg-obtn{
width:100%;
min-height:58px;
display:grid;
grid-template-columns:36px minmax(0,1fr) 18px;
align-items:center;
gap:13px;
border-radius:var(--radius);
padding:11px 14px;
font-family:var(--font-sans);
font-size:15px;
font-weight:650;
text-align:left;
cursor:pointer;
}
.lg-obtn.primary{
background:rgba(89,181,166,.2);
border:1px solid rgba(114,211,197,.44);
color:#f7fbf9;
}
.lg-obtn.primary:hover{background:rgba(89,181,166,.28);border-color:rgba(114,211,197,.62);}
.lg-obtn.secondary{
background:rgba(255,255,255,.08);
border:1px solid rgba(255,255,255,.13);
color:#edf4f2;
}
.lg-obtn.secondary:hover{border-color:rgba(114,211,197,.42);background:rgba(255,255,255,.11);}
.lg-obtn:disabled{
cursor:not-allowed;
opacity:1;
background:rgba(255,255,255,.07);
border-color:rgba(255,255,255,.1);
color:rgba(237,244,242,.58);
}
.lg-obtn:disabled:hover{
background:rgba(255,255,255,.07);
border-color:rgba(255,255,255,.1);
}
.lg-obtn:disabled .ic{
background:rgba(255,255,255,.08);
color:rgba(237,244,242,.58);
}
.lg-obtn:disabled .sub{
color:rgba(237,244,242,.45);
}
.lg-obtn .ic{
width:36px;
height:36px;
display:grid;
place-items:center;
border-radius:8px;
background:rgba(255,255,255,.16);
}
.lg-obtn.secondary .ic{background:rgba(7,17,15,.34);}
.lg-obtn .txt{min-width:0;display:flex;flex-direction:column;gap:1px;}
.lg-obtn .sub{font-size:12px;font-weight:550;color:rgba(237,244,242,.58);}
.lg-obtn.primary .sub{color:rgba(251,250,248,.74);}
.lg-config{
display:flex;
align-items:flex-start;
gap:10px;
margin-top:var(--sp-3);
padding:10px 12px;
border-radius:var(--radius);
background:rgba(154,94,20,.28);
border:1px solid rgba(236,180,91,.16);
color:#f2b75f;
font-size:12.5px;
line-height:1.5;
}
.lg-dev{
margin-top:var(--sp-6);
padding-top:var(--sp-5);
border-top:1px solid rgba(255,255,255,.1);
}
.lg-devhead{
display:flex;
align-items:baseline;
justify-content:space-between;
gap:var(--sp-3);
color:#edf4f2;
font-size:13px;
font-weight:700;
}
.lg-devhead small{color:rgba(237,244,242,.58);font-weight:600;}
.lg-rolepick{display:grid;grid-template-columns:1fr;gap:var(--sp-2);margin-top:var(--sp-3);}
.lg-roleopt{
min-height:50px;
display:grid;
grid-template-columns:10px minmax(0,1fr) 16px;
align-items:center;
gap:10px;
padding:9px 11px;
border:1px solid rgba(255,255,255,.11);
border-radius:var(--radius);
background:rgba(255,255,255,.045);
color:#edf4f2;
text-align:left;
cursor:pointer;
}
.lg-roleopt.is-sel{border-color:rgba(114,211,197,.58);background:rgba(89,181,166,.15);}
.lg-roleopt .d{width:8px;height:8px;border-radius:50%;}
.lg-roleopt .d.learner{background:var(--accent-bright);}
.lg-roleopt .d.teacher{background:#5478c4;}
.lg-roleopt .d.admin{background:#7d818e;}
.lg-roleopt b{display:block;font-size:13px;}
.lg-roleopt small{display:block;margin-top:1px;color:rgba(237,244,242,.58);font-size:12px;}
.lg-devbtn{
width:100%;
min-height:46px;
margin-top:var(--sp-3);
border:1px solid rgba(114,211,197,.58);
border-radius:var(--radius);
background:rgba(89,181,166,.15);
color:#8fe7d9;
font-family:var(--font-sans);
font-size:14px;
font-weight:750;
cursor:pointer;
}
.lg-devbtn:disabled{opacity:.62;cursor:wait;}
.lg-error{
margin:var(--sp-3) 0 0;
color:var(--crit-text);
background:rgba(122,38,38,.28);
border:1px solid rgba(255,145,145,.18);
border-radius:var(--radius);
padding:10px 12px;
font-size:13px;
line-height:1.5;
}
.lg-error small{
display:block;
margin-top:4px;
color:rgba(237,244,242,.56);
font-family:var(--font-num);
font-size:11.5px;
overflow-wrap:anywhere;
}
.lg-note{
margin:var(--sp-5) 0 0;
color:rgba(237,244,242,.52);
font-size:12.5px;
line-height:1.6;
}
@media (max-width:880px){
.lg-root{grid-template-columns:1fr;}
.lg-root::before{display:none;}
.lg-brand{padding:var(--sp-6) var(--sp-5);gap:var(--sp-6);}
.lg-brand::after{display:none;}
.lg-copy h1{font-size:36px;}
.lg-enter{padding:var(--sp-5);}
.lg-panel{max-width:560px;}
}
@media (max-width:480px){
.lg-brand{padding:var(--sp-5);}
.lg-copy h1{font-size:30px;}
.lg-copy p{font-size:15px;}
.lg-enter{padding:var(--sp-4);}
.lg-panel{padding:var(--sp-5);}
}
`;

View file

@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { Button } from "../components/ui";
import { roleHomePath, useAuth } from "../lib/auth";
import { apiUrl, userApi, type LegalDocumentsResponse, type UserProfileResponse } from "../lib/api";
import "./onboarding.css";
interface OnboardingForm {
legal_name: string;
@ -146,7 +147,6 @@ export default function Onboarding() {
return (
<>
<style>{ONBOARDING_CSS}</style>
<main className="ob-page">
<section className="ob-shell" aria-label="가입 정보 입력">
<header className="ob-head">
@ -337,259 +337,3 @@ export default function Onboarding() {
</>
);
}
const ONBOARDING_CSS = `
.ob-page{
min-height:100dvh;
width:100%;
background:var(--bg-app);
color:var(--text-body);
padding:clamp(20px,5vw,56px);
}
.ob-shell{
width:100%;
max-width:900px;
margin:0 auto;
display:grid;
gap:var(--sp-6);
}
.ob-head{
display:grid;
gap:8px;
}
.ob-head p{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:800;
}
.ob-head h1{
margin:0;
color:var(--text-strong);
font-size:clamp(28px,4vw,44px);
line-height:1.18;
letter-spacing:0;
}
.ob-form{
min-width:0;
display:grid;
gap:var(--sp-6);
}
.ob-section{
min-width:0;
display:grid;
gap:var(--sp-4);
padding-bottom:var(--sp-5);
border-bottom:1px solid var(--border-subtle);
}
.ob-section__head h2{
margin:0;
color:var(--text-strong);
font-size:18px;
line-height:1.35;
letter-spacing:0;
}
.ob-avatar{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:var(--sp-3);
align-items:center;
}
.ob-avatar__preview{
width:72px;
height:72px;
border-radius:50%;
display:grid;
place-items:center;
overflow:hidden;
background:var(--accent-tint);
color:var(--accent-deep);
font-size:28px;
font-weight:800;
border:1px solid var(--border-subtle);
}
.ob-avatar__preview img{
width:100%;
height:100%;
display:block;
object-fit:cover;
}
.ob-avatar__body{
min-width:0;
display:grid;
gap:7px;
}
.ob-avatar__body span{
color:var(--text-strong);
font-size:13px;
font-weight:780;
}
.ob-avatar__body p{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.ob-avatar__button{
position:relative;
width:max-content;
min-height:34px;
display:inline-flex;
align-items:center;
justify-content:center;
padding:0 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--text-strong);
font-size:12px;
font-weight:760;
cursor:pointer;
}
.ob-avatar__button input{
position:absolute;
inline-size:1px;
block-size:1px;
opacity:0;
pointer-events:none;
}
.ob-field--wide{
grid-column:1 / -1;
}
.ob-fields{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:var(--sp-3);
}
.ob-form label{
min-width:0;
display:grid;
gap:7px;
}
.ob-form label span{
color:var(--text-muted);
font-size:12px;
font-weight:730;
}
.ob-form input[type="text"],
.ob-form input:not([type]){
min-width:0;
}
.ob-form input,
.ob-form textarea{
width:100%;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-strong);
padding:0 12px;
font:500 var(--fs-sm)/1.2 var(--font-sans);
}
.ob-form input{
min-height:42px;
}
.ob-form textarea{
min-height:92px;
padding:11px 12px;
resize:vertical;
line-height:1.45;
}
.ob-form input:focus,
.ob-form textarea:focus{
outline:2px solid color-mix(in srgb,var(--accent) 24%,transparent);
border-color:var(--accent);
}
.ob-checks{
display:grid;
gap:10px;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.ob-checks label{
grid-template-columns:auto minmax(0,1fr);
align-items:center;
gap:10px;
}
.ob-checks input{
width:18px;
height:18px;
min-height:18px;
padding:0;
accent-color:var(--accent);
}
.ob-legal{
min-width:0;
display:grid;
gap:8px;
}
.ob-legal details{
min-width:0;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.ob-legal summary{
min-width:0;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-height:42px;
padding:0 12px;
cursor:pointer;
}
.ob-legal summary span{
min-width:0;
color:var(--text-strong);
font-size:13px;
font-weight:760;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ob-legal summary b{
flex:none;
color:var(--text-muted);
font-size:11px;
font-weight:700;
}
.ob-note,
.ob-error{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.5;
}
.ob-doc-text{
max-height:260px;
overflow:auto;
white-space:pre-wrap;
color:var(--text-body);
font-size:12.5px;
line-height:1.55;
padding:0 12px 12px;
}
.ob-error{
color:var(--crit-text);
}
.ob-actions{
display:flex;
justify-content:flex-start;
}
@media (max-width:620px){
.ob-page{
padding:14px;
}
.ob-fields{
grid-template-columns:1fr;
}
.ob-avatar{
grid-template-columns:1fr;
}
.ob-actions .vg-btn{
width:100%;
}
}
`;

View file

@ -2,6 +2,7 @@ import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { Button, Icon } from "../components/ui";
import { roleHomePath, useAuth } from "../lib/auth";
import "./pending-approval.css";
export default function PendingApproval() {
const navigate = useNavigate();
@ -33,7 +34,6 @@ export default function PendingApproval() {
return (
<>
<style>{PENDING_APPROVAL_CSS}</style>
<main className="pa-page" aria-label="계정 승인 대기">
<section className="pa-panel">
<div className="pa-mark" aria-hidden="true">
@ -77,100 +77,3 @@ export default function PendingApproval() {
</>
);
}
const PENDING_APPROVAL_CSS = `
.pa-page{
min-height:100dvh;
display:grid;
place-items:center;
padding:clamp(18px,5vw,56px);
background:var(--bg-app);
color:var(--text-body);
}
.pa-panel{
width:min(100%,620px);
display:grid;
gap:var(--sp-4);
padding:clamp(24px,5vw,44px);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 92%,white 8%);
box-shadow:0 18px 50px rgba(28,43,40,.10);
}
.pa-mark{
width:62px;
height:62px;
display:grid;
place-items:center;
border-radius:var(--radius);
background:var(--accent-tint);
color:var(--accent-deep);
border:1px solid color-mix(in srgb,var(--accent) 20%,transparent);
}
.pa-kicker{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:820;
}
.pa-panel h1{
margin:0;
color:var(--text-strong);
font-size:clamp(30px,5vw,46px);
line-height:1.17;
letter-spacing:0;
}
.pa-copy{
margin:0;
color:var(--text-muted);
font-size:15px;
line-height:1.7;
}
.pa-copy b{
color:var(--text-strong);
font-weight:760;
}
.pa-status{
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:12px;
align-items:center;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.pa-status > span{
width:10px;
height:10px;
border-radius:50%;
background:#d89a2b;
box-shadow:0 0 0 5px rgba(216,154,43,.14);
}
.pa-status b{
display:block;
color:var(--text-strong);
font-size:14px;
}
.pa-status small{
display:block;
margin-top:3px;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.pa-actions{
display:flex;
flex-wrap:wrap;
gap:10px;
}
@media (max-width:560px){
.pa-panel{
border-radius:var(--radius);
}
.pa-actions .vg-btn{
width:100%;
}
}
`;

File diff suppressed because it is too large Load diff

View file

@ -14,6 +14,7 @@ import {
type TeacherSafetyAlert,
type TeacherDashboardResponse,
} from "../lib/api";
import "./professor.css";
type LoadState = "loading" | "ready" | "error";
@ -84,7 +85,6 @@ function EmptyState({ title, desc }: { title: string; desc: string }) {
</div>
);
}
export default function Professor() {
const navigate = useNavigate();
const [dashboard, setDashboard] = useState<TeacherDashboardResponse | null>(null);
@ -212,7 +212,6 @@ export default function Professor() {
return (
<AppShell navRole="teacher" wide>
<style>{PF_CSS}</style>
<main className="pf-root">
<header className="pf-head">
<div>
@ -719,920 +718,3 @@ function SafetyAlertRow({ alert }: { alert: TeacherSafetyAlert }) {
</article>
);
}
const PF_CSS = `
.pf-root{
max-width:1280px;
margin:0 auto;
display:flex;
flex-direction:column;
gap:16px;
}
.pf-head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:var(--sp-4);
flex-wrap:wrap;
}
.pf-head__actions{
display:flex;
align-items:center;
justify-content:flex-end;
gap:8px;
flex-wrap:wrap;
}
.pf-head h1{
margin:6px 0 0;
color:var(--text-strong);
font-size:var(--fs-h2);
line-height:1.28;
letter-spacing:0;
}
.pf-head p{
margin:6px 0 0;
max-width:760px;
color:var(--text-body);
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-signal-strip{
order:1;
display:grid;
grid-template-columns:1fr;
gap:0;
min-width:0;
}
.pf-triage{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
align-items:center;
gap:12px;
min-width:0;
padding:14px 16px;
border:1px solid var(--hair);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-triage.is-active{
background:color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface));
}
.pf-triage__copy{
min-width:0;
}
.pf-triage__copy .vg-kicker{
margin-bottom:4px;
}
.pf-triage__copy b{
display:block;
color:var(--text-strong);
font-size:var(--fs-body);
line-height:1.35;
}
.pf-triage__copy span,
.pf-triage__meta small{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-triage__meta{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:0;
min-width:180px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 86%,transparent);
overflow:hidden;
}
.pf-triage__meta span{
display:grid;
align-content:center;
gap:2px;
padding:9px 12px;
}
.pf-triage__meta span + span{
border-left:1px solid var(--hair);
}
.pf-triage__meta b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:17px;
line-height:1;
}
.pf-error{
display:flex;
align-items:center;
gap:10px;
padding:12px 14px;
border-radius:var(--radius);
background:var(--crit-tint);
color:var(--crit-text);
font-size:var(--fs-sm);
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(6,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-kpi{
position:relative;
min-width:0;
padding:14px 16px;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:4px 10px;
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-kpi--primary,
.pf-kpi--warn{
background:color-mix(in srgb,var(--accent-tint) 32%,var(--bg-surface));
}
.pf-kpi--warn{
background:color-mix(in srgb,var(--warn-tint) 36%,var(--bg-surface));
}
.pf-kpi--primary .pf-kpi__ic{
color:var(--text-on-accent);
background:var(--accent);
}
.pf-kpi--warn .pf-kpi__ic{
color:var(--warn-text);
background:var(--warn-tint);
}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--accent);
background:var(--accent-tint);
}
.pf-kpi__lab{
display:block;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-kpi b{
display:block;
color:var(--text-strong);
font-family:var(--font-num);
font-size:24px;
line-height:1;
}
.pf-kpi small{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
}
.pf-workspace{
order:2;
display:grid;
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
}
.pf-queue-stack{
display:flex;
flex-direction:column;
gap:14px;
min-width:0;
}
.pf-section{
display:flex;
flex-direction:column;
gap:10px;
min-width:0;
}
.pf-section__head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:12px;
}
.pf-section__head h2{
margin:4px 0 0;
color:var(--text-strong);
font-size:var(--fs-body);
font-weight:700;
line-height:1.3;
letter-spacing:0;
}
.pf-panel{
padding:0;
overflow:hidden;
}
.pf-studio-card{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:14px;
align-items:center;
padding:14px;
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-studio-card__copy{
min-width:0;
display:grid;
gap:7px;
}
.pf-studio-card__copy b{
color:var(--text-strong);
font-size:var(--fs-body);
}
.pf-studio-card__copy p{
margin:0;
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-studio-card__meta{
display:flex;
flex-wrap:wrap;
gap:6px;
}
.pf-studio-card__meta span{
padding:4px 7px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
}
.pf-studio-card__actions{
display:flex;
justify-content:flex-end;
}
.pf-section--growth{
order:3;
min-width:0;
}
.pf-growth-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-growth-list{
max-height:min(420px,44vh);
overflow:auto;
scrollbar-gutter:stable;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;
padding:12px;
}
.pf-growth-card{
min-width:0;
display:flex;
flex-direction:column;
gap:12px;
padding:13px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2));
box-shadow:none;
}
.pf-growth-card__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
min-width:0;
}
.pf-growth-card__id{
min-width:0;
display:grid;
gap:3px;
}
.pf-growth-card__id b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__id span,
.pf-growth-card__metrics small,
.pf-growth-point span,
.pf-recent__review-state{
color:var(--text-muted);
font-size:12px;
line-height:1.4;
}
.pf-recent__review-state{
display:block;
margin-top:3px;
line-height:1.2;
}
.pf-growth-card__metrics{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
}
.pf-growth-card__metrics span{
min-width:0;
display:grid;
gap:3px;
padding:9px 10px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.pf-growth-card__metrics b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:15px;
line-height:1.15;
white-space:nowrap;
}
.pf-growth-bars{
height:82px;
display:flex;
align-items:flex-end;
gap:6px;
padding:8px 8px 6px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
}
.pf-growth-bar{
flex:1 1 0;
min-width:14px;
height:100%;
display:grid;
grid-template-rows:minmax(0,1fr) 14px;
gap:4px;
align-items:end;
}
.pf-growth-bar i{
display:block;
width:100%;
min-height:6px;
border-radius:6px 6px 3px 3px;
background:linear-gradient(180deg,var(--accent),var(--accent-deep));
}
.pf-growth-bar.is-empty i{
background:repeating-linear-gradient(135deg,var(--paper-2),var(--paper-2) 3px,var(--hair) 3px,var(--hair) 6px);
}
.pf-growth-bar small{
color:var(--text-muted);
font-family:var(--font-num);
font-size:10px;
text-align:center;
line-height:1;
}
.pf-growth-card__tags{
min-height:26px;
display:flex;
flex-wrap:wrap;
gap:6px;
align-content:flex-start;
}
.pf-growth-card__tags span{
max-width:100%;
padding:4px 7px;
border:1px solid var(--paper-2);
border-radius:999px;
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__points{
display:grid;
gap:7px;
}
.pf-growth-point{
min-width:0;
display:grid;
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-growth-point b{
color:var(--text-strong);
font-size:12px;
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-point span{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-empty{
min-height:118px;
display:grid;
place-items:center;
gap:6px;
padding:var(--sp-5);
text-align:center;
}
.pf-empty b{
color:var(--text-strong);
font-size:var(--fs-body);
}
.pf-empty span{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-list{
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-personas{
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-persona{
display:grid;
grid-template-columns:minmax(0,1fr);
gap:8px;
align-items:start;
padding:12px;
border-top:1px solid var(--paper-2);
}
.pf-persona:first-child{border-top:0;}
.pf-persona__main{
min-width:0;
display:grid;
grid-template-columns:40px minmax(0,1fr);
gap:10px;
align-items:center;
}
.pf-persona__code{
width:40px;
height:32px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--accent-deep);
background:var(--accent-tint);
font-family:var(--font-num);
font-weight:800;
font-size:12px;
}
.pf-persona__main b{
display:block;
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__main span,
.pf-persona p,
.pf-persona__meta span{
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.45;
}
.pf-persona p{
margin:0;
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__meta{
display:flex;
align-items:center;
gap:10px;
justify-content:space-between;
white-space:nowrap;
}
.pf-persona__actions{
display:flex;
justify-content:flex-end;
gap:8px;
min-width:0;
}
.pf-persona__actions .vg-btn{
min-width:72px;
padding-inline:10px;
}
.pf-alerts{
max-height:min(300px,38vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-alert{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
gap:10px;
align-items:center;
padding:12px;
border-top:1px solid var(--paper-2);
background:color-mix(in srgb,var(--warn-tint) 34%,transparent);
}
.pf-alert:first-child{border-top:0;}
.pf-alert__ic{
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--warn-text);
background:var(--warn-tint);
}
.pf-alert__main{
min-width:0;
display:grid;
gap:3px;
}
.pf-alert__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.pf-alert__main span,
.pf-alert__main code,
.pf-alert__resource span{
color:var(--text-muted);
font-size:12px;
}
.pf-alert__main code{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-alert__resource{
display:grid;
gap:2px;
justify-items:end;
min-width:86px;
}
.pf-alert__resource b{
color:var(--warn-text);
font-family:var(--font-num);
font-size:18px;
}
.pf-session{
width:100%;
font:inherit;
text-align:left;
background:transparent;
color:inherit;
display:grid;
grid-template-columns:8px minmax(0,1fr) auto;
gap:8px 10px;
align-items:start;
padding:12px;
border:0;
border-top:1px solid var(--paper-2);
cursor:default;
}
.pf-session:first-child{border-top:0;}
.pf-session--action{
cursor:pointer;
}
.pf-session--action:hover{
background:var(--bg-surface-2);
}
.pf-session--action:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-session__dot{
width:8px;
height:8px;
border-radius:50%;
background:var(--accent);
}
.pf-session__main{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-session__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.pf-session__main span,.pf-session__main code{
color:var(--text-muted);
font-size:var(--fs-xs);
overflow-wrap:anywhere;
}
.pf-session__main code,.pf-recent__learner code{
font-family:var(--font-num);
overflow:hidden;
text-overflow:ellipsis;
}
.pf-session__meta{
grid-column:2 / 4;
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
color:var(--text-muted);
font-family:var(--font-num);
font-size:var(--fs-xs);
white-space:normal;
}
.pf-session__open{
grid-column:3;
grid-row:1;
display:inline-flex;
align-items:center;
gap:5px;
align-self:center;
justify-self:end;
min-height:30px;
padding:0 9px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-session--action:hover .pf-session__open{
border-color:var(--accent);
}
.pf-recent-list{
max-height:min(620px,calc(100vh - 220px));
overflow:auto;
scrollbar-gutter:stable;
min-width:0;
}
.pf-recent-head,
.pf-recent-row{
display:grid;
grid-template-columns:
minmax(128px,1.25fr)
minmax(58px,.55fr)
minmax(66px,.55fr)
minmax(82px,.85fr)
minmax(32px,.35fr)
minmax(70px,.6fr)
minmax(66px,.55fr)
minmax(86px,.65fr);
align-items:center;
gap:8px;
min-width:0;
}
.pf-recent-head{
position:sticky;
top:0;
z-index:1;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
padding:9px 12px;
border-bottom:1px solid var(--hair);
background:var(--bg-surface);
white-space:nowrap;
}
.pf-recent-row{
width:100%;
font:inherit;
text-align:left;
color:inherit;
background:transparent;
padding:10px 12px;
border:0;
border-top:1px solid var(--paper-2);
}
.pf-recent-row--action{
cursor:pointer;
}
.pf-recent-row--action:hover{
background:var(--bg-surface-2);
}
.pf-recent-row--action:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-recent-head + .pf-recent-row{
border-top:0;
}
.pf-recent__learner,
.pf-recent__cell{
min-width:0;
color:var(--text-body);
font-size:var(--fs-sm);
overflow-wrap:anywhere;
}
.pf-recent__cell::before{
display:none;
}
.pf-recent__learner{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-recent__learner b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow-wrap:anywhere;
}
.pf-recent__learner code{
max-width:100%;
color:var(--text-muted);
font-size:11px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.pf-recent__cell .vg-badge{
justify-self:start;
}
.pf-recent__cell--open{
display:flex;
justify-content:flex-end;
}
.pf-recent__open{
display:inline-flex;
align-items:center;
justify-content:center;
gap:4px;
min-height:30px;
padding:0 7px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-recent-row--action:hover .pf-recent__open{
border-color:var(--accent);
}
@media (max-width:1100px){
.pf-signal-strip,
.pf-workspace{
grid-template-columns:1fr;
}
.pf-triage{
grid-template-columns:auto minmax(0,max-content) auto;
justify-content:start;
}
.pf-kpis{
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-growth-list{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-list,
.pf-personas{
max-height:360px;
}
.pf-recent-list{
max-height:460px;
}
}
@media (max-width:860px){
.pf-head__actions{
width:100%;
justify-content:flex-start;
}
.pf-triage{
grid-template-columns:auto minmax(0,1fr);
}
.pf-triage__meta{
grid-column:1 / -1;
width:100%;
}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi:nth-child(n+2){border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-growth-list{
grid-template-columns:1fr;
}
.pf-recent-list{
display:flex;
flex-direction:column;
gap:10px;
padding:10px;
background:var(--bg-surface-2);
overflow-x:hidden;
}
.pf-recent-head{
display:none;
}
.pf-recent-row{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px 12px;
padding:12px;
border:1px solid var(--paper-2);
border-radius:var(--radius);
background:var(--bg-surface);
}
.pf-recent__learner{
grid-column:1 / -1;
padding-bottom:10px;
border-bottom:1px solid var(--paper-2);
}
.pf-recent__learner code{
white-space:normal;
overflow-wrap:anywhere;
}
.pf-recent__cell{
display:grid;
grid-template-columns:minmax(74px,.4fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-recent__cell--open{
justify-content:stretch;
}
.pf-recent__cell::before{
display:block;
content:attr(data-label);
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
line-height:1.35;
}
.pf-session{
grid-template-columns:8px minmax(0,1fr);
}
.pf-session__meta{
grid-column:2;
justify-content:flex-start;
}
.pf-session__open{
grid-column:2;
grid-row:auto;
justify-self:start;
}
}
@media (max-width:520px){
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi,
.pf-kpi + .pf-kpi{border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-persona__actions{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-persona__actions .vg-btn{
width:100%;
}
.pf-growth-list{
padding:10px;
}
.pf-growth-card__metrics{
grid-template-columns:1fr;
}
.pf-growth-point{
grid-template-columns:1fr;
gap:2px;
}
.pf-growth-point b,
.pf-growth-point span{
white-space:normal;
}
.pf-alert{
grid-template-columns:auto minmax(0,1fr);
}
.pf-alert__resource{
grid-column:2;
justify-items:start;
}
.pf-recent-list{
padding:8px;
}
.pf-recent-row{
grid-template-columns:1fr;
gap:10px;
}
.pf-recent__cell{
grid-template-columns:minmax(64px,.32fr) minmax(0,1fr);
}
.pf-recent__cell--open .pf-recent__open{
justify-self:start;
}
}
`;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,61 @@
import { Icon } from "../../components/ui/Icon";
interface LoginBrandProps {
allowedDomains: string[];
oauthChecking: boolean;
}
function VignetteMark() {
return (
<span className="lg-mark" aria-hidden="true">
<svg viewBox="0 0 26 26" width={26} height={26} fill="none">
<circle cx="13" cy="13" r="11" stroke="currentColor" strokeWidth="2" />
<path
d="M8 14.5c1.4 1.7 3 2.5 5 2.5s3.6-.8 5-2.5"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
/>
<circle cx="13" cy="9" r="1.6" fill="var(--clay)" />
</svg>
</span>
);
}
export function LoginBrand({ allowedDomains, oauthChecking }: LoginBrandProps) {
return (
<section className="lg-brand" aria-label="Vignette">
<div className="lg-wordmark">
<VignetteMark />
<span>Vignette</span>
</div>
<div className="lg-copy">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
</span>
<h1>
<span className="lg-highlight"> </span>, <br />
.
</h1>
<p>
Vignette는 , , .
.
</p>
</div>
<div className="lg-policy">
<span>
<Icon name="shield" size={17} />
</span>
{allowedDomains.length ? (
allowedDomains.map((domain) => <b key={domain}>{domain}</b>)
) : (
<b>{oauthChecking ? "확인 중" : "설정 필요"}</b>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,169 @@
import { Icon, type IconName } from "../../components/ui/Icon";
import type { Role } from "../../lib/auth";
export interface LoginRoleOption {
role: Role;
label: string;
desc: string;
dotClass: string;
}
interface LoginOAuthView {
ready: boolean;
primaryDomainLabel: string;
secondaryDomainLabel: string;
statusIcon: Extract<IconName, "alert" | "info">;
statusMessage: string;
onStart: () => void;
}
interface LoginDevAccessView {
ready: boolean;
selected: Role;
pending: boolean;
options: LoginRoleOption[];
onSelect: (role: Role) => void;
onEnter: (role: Role) => void;
}
interface LoginErrorView {
message: string | null;
reason: string | null;
}
interface LoginPanelProps {
oauth: LoginOAuthView;
devAccess: LoginDevAccessView;
error: LoginErrorView;
}
function LoginProviderButton({
variant,
icon,
label,
subLabel,
disabled,
onClick,
}: {
variant: "primary" | "secondary";
icon: Extract<IconName, "google" | "school">;
label: string;
subLabel: string;
disabled: boolean;
onClick: () => void;
}) {
return (
<button className={`lg-obtn ${variant}`} type="button" onClick={onClick} disabled={disabled}>
<span className="ic">
<Icon name={icon} size={19} strokeWidth={1.8} />
</span>
<span className="txt">
{label}
<span className="sub">{subLabel}</span>
</span>
<Icon name="chevron-right" size={18} strokeWidth={2} />
</button>
);
}
function LoginErrorNotice({ error }: { error: LoginErrorView }) {
if (!error.message) return null;
return (
<p className="lg-error">
{error.message}
{error.reason ? <small> : {error.reason}</small> : null}
</p>
);
}
function LoginDevAccess({ devAccess, error }: { devAccess: LoginDevAccessView; error: LoginErrorView }) {
if (!devAccess.ready) return null;
return (
<div className="lg-dev">
<div className="lg-devhead">
<span> </span>
<small> </small>
</div>
<div className="lg-rolepick" role="radiogroup" aria-label="로컬 테스트 역할">
{devAccess.options.map((opt) => (
<button
key={opt.role}
type="button"
role="radio"
aria-checked={devAccess.selected === opt.role}
className={`lg-roleopt ${devAccess.selected === opt.role ? "is-sel" : ""}`}
onClick={() => devAccess.onSelect(opt.role)}
>
<span className={`d ${opt.dotClass}`} aria-hidden="true" />
<span>
<b>{opt.label}</b>
<small>{opt.desc}</small>
</span>
{devAccess.selected === opt.role ? <Icon name="check" size={15} /> : null}
</button>
))}
</div>
<button
className="lg-devbtn"
type="button"
onClick={() => devAccess.onEnter(devAccess.selected)}
disabled={devAccess.pending}
>
{devAccess.pending ? "테스트 계정 확인 중" : "로컬 테스트 계정으로 계속"}
</button>
<LoginErrorNotice error={error} />
</div>
);
}
export function LoginPanel({ oauth, devAccess, error }: LoginPanelProps) {
return (
<section className="lg-enter" aria-label="로그인">
<div className="lg-panel">
<span className="lg-kicker">
<span className="d" aria-hidden="true" />
</span>
<h2></h2>
<p className="lg-lead">
Google .
</p>
<div className="lg-actions">
<LoginProviderButton
variant="primary"
icon="school"
label="학교 Google 계정으로 계속"
subLabel={oauth.primaryDomainLabel}
disabled={!oauth.ready}
onClick={oauth.onStart}
/>
<LoginProviderButton
variant="secondary"
icon="google"
label="Google 계정으로 계속"
subLabel={oauth.secondaryDomainLabel}
disabled={!oauth.ready}
onClick={oauth.onStart}
/>
</div>
{!oauth.ready ? (
<div className="lg-config" role="status">
<Icon name={oauth.statusIcon} size={17} />
<span>{oauth.statusMessage}</span>
</div>
) : null}
<LoginDevAccess devAccess={devAccess} error={error} />
{!devAccess.ready ? <LoginErrorNotice error={error} /> : null}
<p className="lg-note">
. , , .
</p>
</div>
</section>
);
}

View file

@ -0,0 +1,484 @@
.lg-root {
min-height: 100dvh;
position: relative;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, 500px);
background:
linear-gradient(
90deg,
rgba(7, 16, 14, 0.84) 0%,
rgba(10, 21, 19, 0.72) 48%,
rgba(10, 21, 19, 0.55) 74%,
rgba(10, 21, 19, 0.62) 100%
),
var(--asset-login-room) center / cover no-repeat;
color: #edf4f2;
overflow-x: hidden;
overflow-y: auto;
}
.lg-root::before {
content: "";
position: absolute;
inset: 0;
background: linear-gradient(180deg, rgba(3, 9, 8, 0.18), rgba(3, 9, 8, 0.42));
pointer-events: none;
}
.lg-brand,
.lg-enter {
position: relative;
z-index: 1;
}
.lg-brand {
min-width: 0;
width: 100%;
position: relative;
display: flex;
flex-direction: column;
justify-content: space-between;
gap: var(--sp-7);
padding: var(--sp-7);
background: transparent;
color: #edf4f2;
overflow: hidden;
}
.lg-brand::after {
display: none;
}
.lg-wordmark {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 10px;
font-size: 19px;
font-weight: 700;
letter-spacing: 0;
color: #edf4f2;
}
.lg-mark {
display: grid;
place-items: center;
color: var(--accent-bright);
}
.lg-copy {
max-width: 620px;
}
.lg-copy,
.lg-policy {
position: relative;
z-index: 1;
}
.lg-kicker {
display: inline-flex;
align-items: center;
gap: 8px;
font-family: var(--font-num);
font-size: 12px;
font-weight: 700;
letter-spacing: 0;
text-transform: uppercase;
color: var(--accent-bright);
}
.lg-kicker .d {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.lg-highlight {
display: inline-block;
position: relative;
z-index: 0;
padding: 0 4px;
background: linear-gradient(90deg, #59b5a6, #8ee4d6);
-webkit-background-clip: text;
background-clip: text;
-webkit-text-fill-color: transparent;
color: transparent;
}
.lg-copy h1 {
margin: var(--sp-4) 0 0;
max-width: 640px;
font-size: 56px;
line-height: 1.12;
letter-spacing: 0;
font-weight: 760;
}
.lg-copy p {
margin: var(--sp-5) 0 0;
max-width: 560px;
color: rgba(237, 244, 242, 0.72);
font-size: 17px;
line-height: 1.75;
}
.lg-policy {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
width: max-content;
max-width: min(430px, 100%);
padding: 14px 16px;
border: 1px solid rgba(255, 255, 255, 0.14);
border-radius: var(--radius-lg);
background: rgba(237, 244, 242, 0.08);
backdrop-filter: blur(18px) saturate(1.08);
-webkit-backdrop-filter: blur(18px) saturate(1.08);
color: rgba(237, 244, 242, 0.66);
font-size: 13px;
}
.lg-policy span,
.lg-policy b {
display: inline-flex;
align-items: center;
gap: 7px;
}
.lg-policy b {
color: #edf4f2;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 999px;
padding: 5px 10px;
font-weight: 650;
}
.lg-enter {
min-width: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--sp-6);
background: transparent;
}
.lg-panel {
width: 100%;
max-width: 430px;
background: rgba(11, 25, 22, 0.64);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: var(--radius-lg);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.34), inset 0 1px 0 rgba(255, 255, 255, 0.08);
backdrop-filter: blur(24px) saturate(1.14);
-webkit-backdrop-filter: blur(24px) saturate(1.14);
padding: var(--sp-6);
color: #edf4f2;
}
.lg-panel h2 {
margin: var(--sp-3) 0 0;
font-size: 28px;
line-height: 1.25;
letter-spacing: 0;
color: #f7fbf9;
}
.lg-lead {
margin: 10px 0 0;
color: rgba(237, 244, 242, 0.76);
font-size: 14px;
line-height: 1.65;
}
.lg-actions {
display: flex;
flex-direction: column;
gap: var(--sp-3);
margin-top: var(--sp-6);
}
.lg-obtn {
width: 100%;
min-height: 58px;
display: grid;
grid-template-columns: 36px minmax(0, 1fr) 18px;
align-items: center;
gap: 13px;
border-radius: var(--radius);
padding: 11px 14px;
font-family: var(--font-sans);
font-size: 15px;
font-weight: 650;
text-align: left;
cursor: pointer;
}
.lg-obtn.primary {
background: rgba(89, 181, 166, 0.2);
border: 1px solid rgba(114, 211, 197, 0.44);
color: #f7fbf9;
}
.lg-obtn.primary:hover {
background: rgba(89, 181, 166, 0.28);
border-color: rgba(114, 211, 197, 0.62);
}
.lg-obtn.secondary {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.13);
color: #edf4f2;
}
.lg-obtn.secondary:hover {
border-color: rgba(114, 211, 197, 0.42);
background: rgba(255, 255, 255, 0.11);
}
.lg-obtn:disabled {
cursor: not-allowed;
opacity: 1;
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(237, 244, 242, 0.58);
}
.lg-obtn:disabled:hover {
background: rgba(255, 255, 255, 0.07);
border-color: rgba(255, 255, 255, 0.1);
}
.lg-obtn:disabled .ic {
background: rgba(255, 255, 255, 0.08);
color: rgba(237, 244, 242, 0.58);
}
.lg-obtn:disabled .sub {
color: rgba(237, 244, 242, 0.45);
}
.lg-obtn .ic {
width: 36px;
height: 36px;
display: grid;
place-items: center;
border-radius: 8px;
background: rgba(255, 255, 255, 0.16);
}
.lg-obtn.secondary .ic {
background: rgba(7, 17, 15, 0.34);
}
.lg-obtn .txt {
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.lg-obtn .sub {
font-size: 12px;
font-weight: 550;
color: rgba(237, 244, 242, 0.58);
}
.lg-obtn.primary .sub {
color: rgba(251, 250, 248, 0.74);
}
.lg-config {
display: flex;
align-items: flex-start;
gap: 10px;
margin-top: var(--sp-3);
padding: 10px 12px;
border-radius: var(--radius);
background: rgba(154, 94, 20, 0.28);
border: 1px solid rgba(236, 180, 91, 0.16);
color: #f2b75f;
font-size: 12.5px;
line-height: 1.5;
}
.lg-dev {
margin-top: var(--sp-6);
padding-top: var(--sp-5);
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.lg-devhead {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--sp-3);
color: #edf4f2;
font-size: 13px;
font-weight: 700;
}
.lg-devhead small {
color: rgba(237, 244, 242, 0.58);
font-weight: 600;
}
.lg-rolepick {
display: grid;
grid-template-columns: 1fr;
gap: var(--sp-2);
margin-top: var(--sp-3);
}
.lg-roleopt {
min-height: 50px;
display: grid;
grid-template-columns: 10px minmax(0, 1fr) 16px;
align-items: center;
gap: 10px;
padding: 9px 11px;
border: 1px solid rgba(255, 255, 255, 0.11);
border-radius: var(--radius);
background: rgba(255, 255, 255, 0.045);
color: #edf4f2;
text-align: left;
cursor: pointer;
}
.lg-roleopt.is-sel {
border-color: rgba(114, 211, 197, 0.58);
background: rgba(89, 181, 166, 0.15);
}
.lg-roleopt .d {
width: 8px;
height: 8px;
border-radius: 50%;
}
.lg-roleopt .d.learner {
background: var(--accent-bright);
}
.lg-roleopt .d.teacher {
background: #5478c4;
}
.lg-roleopt .d.admin {
background: #7d818e;
}
.lg-roleopt b {
display: block;
font-size: 13px;
}
.lg-roleopt small {
display: block;
margin-top: 1px;
color: rgba(237, 244, 242, 0.58);
font-size: 12px;
}
.lg-devbtn {
width: 100%;
min-height: 46px;
margin-top: var(--sp-3);
border: 1px solid rgba(114, 211, 197, 0.58);
border-radius: var(--radius);
background: rgba(89, 181, 166, 0.15);
color: #8fe7d9;
font-family: var(--font-sans);
font-size: 14px;
font-weight: 750;
cursor: pointer;
}
.lg-devbtn:disabled {
opacity: 0.62;
cursor: wait;
}
.lg-error {
margin: var(--sp-3) 0 0;
color: var(--crit-text);
background: rgba(122, 38, 38, 0.28);
border: 1px solid rgba(255, 145, 145, 0.18);
border-radius: var(--radius);
padding: 10px 12px;
font-size: 13px;
line-height: 1.5;
}
.lg-error small {
display: block;
margin-top: 4px;
color: rgba(237, 244, 242, 0.56);
font-family: var(--font-num);
font-size: 11.5px;
overflow-wrap: anywhere;
}
.lg-note {
margin: var(--sp-5) 0 0;
color: rgba(237, 244, 242, 0.52);
font-size: 12.5px;
line-height: 1.6;
}
@media (max-width: 880px) {
.lg-root {
grid-template-columns: 1fr;
}
.lg-root::before {
display: none;
}
.lg-brand {
padding: var(--sp-6) var(--sp-5);
gap: var(--sp-6);
}
.lg-brand::after {
display: none;
}
.lg-copy h1 {
font-size: 36px;
}
.lg-enter {
padding: var(--sp-5);
}
.lg-panel {
max-width: 560px;
}
}
@media (max-width: 480px) {
.lg-brand {
padding: var(--sp-5);
}
.lg-copy h1 {
font-size: 30px;
}
.lg-copy p {
font-size: 15px;
}
.lg-enter {
padding: var(--sp-4);
}
.lg-panel {
padding: var(--sp-5);
}
}

View file

@ -1,134 +1,253 @@
.ob-root {
width: min(100%, 1040px);
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
.ob-page{
min-height:100dvh;
width:100%;
background:var(--bg-app);
color:var(--text-body);
padding:clamp(20px,5vw,56px);
}
.ob-head h1 {
margin: 6px 0 0;
color: var(--text-strong);
font-size: var(--fs-h2);
line-height: 1.28;
letter-spacing: 0;
.ob-shell{
width:100%;
max-width:900px;
margin:0 auto;
display:grid;
gap:var(--sp-6);
}
.ob-head p {
margin: 8px 0 0;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.55;
.ob-head{
display:grid;
gap:8px;
}
.ob-alert {
display: flex;
align-items: center;
gap: 10px;
padding: 12px 14px;
border-radius: var(--radius);
background: var(--crit-tint);
color: var(--crit-text);
font-size: var(--fs-sm);
.ob-head p{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:800;
}
.ob-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 14px;
align-items: start;
.ob-head h1{
margin:0;
color:var(--text-strong);
font-size:clamp(28px,4vw,44px);
line-height:1.18;
letter-spacing:0;
}
.ob-panel {
min-width: 0;
border: 1px solid var(--hair);
border-radius: var(--radius);
background: var(--bg-surface);
box-shadow: var(--shadow-sm);
.ob-form{
min-width:0;
display:grid;
gap:var(--sp-6);
}
.ob-form {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
padding: 16px;
.ob-section{
min-width:0;
display:grid;
gap:var(--sp-4);
padding-bottom:var(--sp-5);
border-bottom:1px solid var(--border-subtle);
}
.ob-form .vg-btn,
.ob-check {
grid-column: 1 / -1;
.ob-section__head h2{
margin:0;
color:var(--text-strong);
font-size:18px;
line-height:1.35;
letter-spacing:0;
}
.ob-check {
display: flex;
align-items: center;
gap: 9px;
color: var(--text-body);
font-size: var(--fs-sm);
line-height: 1.4;
.ob-avatar{
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:var(--sp-3);
align-items:center;
}
.ob-check input {
width: 17px;
height: 17px;
accent-color: var(--accent);
.ob-avatar__preview{
width:72px;
height:72px;
border-radius:50%;
display:grid;
place-items:center;
overflow:hidden;
background:var(--accent-tint);
color:var(--accent-deep);
font-size:28px;
font-weight:800;
border:1px solid var(--border-subtle);
}
.ob-docs {
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
.ob-avatar__preview img{
width:100%;
height:100%;
display:block;
object-fit:cover;
}
.ob-docs h2 {
margin: 6px 0 0;
color: var(--text-strong);
font-size: var(--fs-body);
.ob-avatar__body{
min-width:0;
display:grid;
gap:7px;
}
.ob-docs p {
margin: 6px 0 0;
color: var(--text-body);
font-size: var(--fs-xs);
line-height: 1.55;
.ob-avatar__body span{
color:var(--text-strong);
font-size:13px;
font-weight:780;
}
.ob-docs article {
min-width: 0;
max-height: 220px;
overflow: auto;
padding: 12px;
border: 1px solid var(--hair);
border-radius: var(--radius-sm);
background: var(--bg-surface-2);
.ob-avatar__body p{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.ob-docs b,
.ob-docs small {
display: block;
.ob-avatar__button{
position:relative;
width:max-content;
min-height:34px;
display:inline-flex;
align-items:center;
justify-content:center;
padding:0 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--text-strong);
font-size:12px;
font-weight:760;
cursor:pointer;
}
.ob-docs b {
color: var(--text-strong);
font-size: var(--fs-sm);
.ob-avatar__button input{
position:absolute;
inline-size:1px;
block-size:1px;
opacity:0;
pointer-events:none;
}
.ob-docs small {
margin-top: 2px;
color: var(--text-muted);
font-family: var(--font-num);
font-size: var(--fs-xs);
.ob-field--wide{
grid-column:1 / -1;
}
@media (max-width: 860px) {
.ob-grid {
grid-template-columns: 1fr;
}
}
@media (max-width: 560px) {
.ob-form {
grid-template-columns: 1fr;
.ob-fields{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:var(--sp-3);
}
.ob-form label{
min-width:0;
display:grid;
gap:7px;
}
.ob-form label span{
color:var(--text-muted);
font-size:12px;
font-weight:730;
}
.ob-form input[type="text"],
.ob-form input:not([type]){
min-width:0;
}
.ob-form input,
.ob-form textarea{
width:100%;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-strong);
padding:0 12px;
font:500 var(--fs-sm)/1.2 var(--font-sans);
}
.ob-form input{
min-height:42px;
}
.ob-form textarea{
min-height:92px;
padding:11px 12px;
resize:vertical;
line-height:1.45;
}
.ob-form input:focus,
.ob-form textarea:focus{
outline:2px solid color-mix(in srgb,var(--accent) 24%,transparent);
border-color:var(--accent);
}
.ob-checks{
display:grid;
gap:10px;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.ob-checks label{
grid-template-columns:auto minmax(0,1fr);
align-items:center;
gap:10px;
}
.ob-checks input{
width:18px;
height:18px;
min-height:18px;
padding:0;
accent-color:var(--accent);
}
.ob-legal{
min-width:0;
display:grid;
gap:8px;
}
.ob-legal details{
min-width:0;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.ob-legal summary{
min-width:0;
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-height:42px;
padding:0 12px;
cursor:pointer;
}
.ob-legal summary span{
min-width:0;
color:var(--text-strong);
font-size:13px;
font-weight:760;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ob-legal summary b{
flex:none;
color:var(--text-muted);
font-size:11px;
font-weight:700;
}
.ob-note,
.ob-error{
margin:0;
color:var(--text-muted);
font-size:12.5px;
line-height:1.5;
}
.ob-doc-text{
max-height:260px;
overflow:auto;
white-space:pre-wrap;
color:var(--text-body);
font-size:12.5px;
line-height:1.55;
padding:0 12px 12px;
}
.ob-error{
color:var(--crit-text);
}
.ob-actions{
display:flex;
justify-content:flex-start;
}
@media (max-width:620px){
.ob-page{
padding:14px;
}
.ob-fields{
grid-template-columns:1fr;
}
.ob-avatar{
grid-template-columns:1fr;
}
.ob-actions .vg-btn{
width:100%;
}
}

View file

@ -0,0 +1,94 @@
.pa-page{
min-height:100dvh;
display:grid;
place-items:center;
padding:clamp(18px,5vw,56px);
background:var(--bg-app);
color:var(--text-body);
}
.pa-panel{
width:min(100%,620px);
display:grid;
gap:var(--sp-4);
padding:clamp(24px,5vw,44px);
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 92%,white 8%);
box-shadow:0 18px 50px rgba(28,43,40,.10);
}
.pa-mark{
width:62px;
height:62px;
display:grid;
place-items:center;
border-radius:var(--radius);
background:var(--accent-tint);
color:var(--accent-deep);
border:1px solid color-mix(in srgb,var(--accent) 20%,transparent);
}
.pa-kicker{
margin:0;
color:var(--accent-deep);
font-size:12px;
font-weight:820;
}
.pa-panel h1{
margin:0;
color:var(--text-strong);
font-size:clamp(30px,5vw,46px);
line-height:1.17;
letter-spacing:0;
}
.pa-copy{
margin:0;
color:var(--text-muted);
font-size:15px;
line-height:1.7;
}
.pa-copy b{
color:var(--text-strong);
font-weight:760;
}
.pa-status{
min-width:0;
display:grid;
grid-template-columns:auto minmax(0,1fr);
gap:12px;
align-items:center;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface-2);
}
.pa-status > span{
width:10px;
height:10px;
border-radius:50%;
background:#d89a2b;
box-shadow:0 0 0 5px rgba(216,154,43,.14);
}
.pa-status b{
display:block;
color:var(--text-strong);
font-size:14px;
}
.pa-status small{
display:block;
margin-top:3px;
color:var(--text-muted);
font-size:12.5px;
line-height:1.45;
}
.pa-actions{
display:flex;
flex-wrap:wrap;
gap:10px;
}
@media (max-width:560px){
.pa-panel{
border-radius:var(--radius);
}
.pa-actions .vg-btn{
width:100%;
}
}

View file

@ -0,0 +1,688 @@
.ps-root{
display:grid;
gap:20px;
}
.ps-head{
display:flex;
justify-content:space-between;
align-items:flex-start;
gap:16px;
}
.ps-head h1{
margin:4px 0 0;
font-size:clamp(24px,3vw,38px);
line-height:1.16;
letter-spacing:0;
color:var(--text-strong);
}
.ps-head__actions{
display:flex;
gap:8px;
flex-wrap:wrap;
justify-content:flex-end;
}
.ps-error{
display:flex;
align-items:center;
gap:8px;
padding:12px 14px;
border:1px solid var(--crit-solid);
border-radius:var(--radius);
color:var(--crit-text);
background:var(--crit-tint);
}
.ps-layout{
display:grid;
grid-template-columns:minmax(220px,300px) minmax(460px,1fr) minmax(240px,320px);
gap:16px;
align-items:start;
}
.ps-rail,
.ps-inspector{
position:sticky;
top:76px;
display:grid;
gap:14px;
min-width:0;
}
.ps-rail__head,
.ps-editor__top,
.ps-workflow,
.ps-inspector__block{
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
padding:16px;
box-shadow:var(--shadow-sm);
}
.ps-rail__head,
.ps-editor__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:12px;
}
.ps-rail__head b,
.ps-editor__top h2{
display:block;
margin:4px 0 0;
color:var(--text-strong);
}
.ps-editor__top h2{
font-size:22px;
line-height:1.25;
letter-spacing:0;
}
.ps-list,
.ps-approved,
.ps-review-cards,
.ps-check{
display:grid;
gap:8px;
}
.ps-list-row{
width:100%;
display:grid;
grid-template-columns:42px minmax(0,1fr);
gap:10px;
align-items:center;
padding:10px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
color:inherit;
text-align:left;
cursor:pointer;
}
.ps-list-row:hover,
.ps-list-row.is-active{
border-color:var(--accent);
background:var(--accent-tint);
}
.ps-list-row:disabled{
opacity:.55;
cursor:not-allowed;
}
.ps-list-row__code{
display:grid;
place-items:center;
min-width:38px;
min-height:34px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--bg-surface-2);
font-weight:760;
font-family:var(--font-num);
}
.ps-list-row__body{
min-width:0;
}
.ps-list-row__body b,
.ps-list-row__body small{
display:block;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ps-list-row__body small,
.ps-muted,
.ps-approved-row span,
.ps-review span{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.ps-approved{
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
}
.ps-workflow{
display:grid;
gap:10px;
}
.ps-workflow-step{
display:grid;
grid-template-columns:30px minmax(0,1fr);
align-items:center;
gap:10px;
padding:10px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-muted);
}
.ps-workflow-step > span{
display:grid;
place-items:center;
width:30px;
height:30px;
border:1px solid var(--border-subtle);
border-radius:50%;
background:var(--bg-surface);
color:var(--text-muted);
font-family:var(--font-num);
font-weight:760;
}
.ps-workflow-step b,
.ps-workflow-step small{
display:block;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ps-workflow-step b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.ps-workflow-step small{
margin-top:2px;
font-size:11px;
}
.ps-workflow-step.is-current{
border-color:var(--accent);
background:var(--accent-tint);
color:var(--accent-deep);
}
.ps-workflow-step.is-current > span,
.ps-workflow-step.is-done > span{
border-color:var(--accent);
background:var(--accent);
color:var(--text-on-accent);
}
.ps-approved-row{
display:grid;
grid-template-columns:38px minmax(0,1fr);
gap:8px 10px;
align-items:center;
padding:8px 0;
border-top:1px solid var(--hair);
}
.ps-approved-row:first-of-type{border-top:0;}
.ps-approved-row__code{
font-family:var(--font-num);
font-size:var(--fs-xs);
color:var(--text-muted);
}
.ps-approved-row__body{
min-width:0;
display:grid;
gap:2px;
}
.ps-approved-row b{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
font-size:var(--fs-xs);
}
.ps-approved-row small{
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
color:var(--text-muted);
font-size:11px;
}
.ps-approved-row__actions{
grid-column:1 / -1;
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:6px;
}
.ps-approved-row__actions .vg-btn{
width:100%;
}
.ps-editor{
display:grid;
gap:14px;
min-width:0;
}
.ps-source-panel{
display:grid;
gap:12px;
padding:16px;
}
.ps-source-panel__head,
.ps-source-panel__actions{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:12px;
}
.ps-source-panel__head b{
display:block;
margin-top:4px;
color:var(--text-strong);
font-size:var(--fs-body);
}
.ps-source-grid{
display:grid;
grid-template-columns:minmax(220px,.42fr) minmax(280px,.58fr);
gap:12px;
}
.ps-source-grid .ps-field--wide{
grid-column:1 / -1;
}
.ps-upload-card{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
align-items:center;
gap:14px;
padding:16px;
border:1px dashed var(--accent);
border-radius:var(--radius);
background:var(--accent-tint);
}
.ps-upload-card b,
.ps-upload-card span{
display:block;
}
.ps-upload-card b{
margin-top:5px;
color:var(--text-strong);
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.ps-upload-card span{
margin-top:4px;
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.45;
}
.ps-file-input{
display:none;
}
.ps-source-panel__actions span{
min-width:0;
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.45;
}
.ps-tabs,
.ps-segments{
display:flex;
flex-wrap:wrap;
gap:6px;
}
.ps-tabs button,
.ps-segments button{
min-height:34px;
padding:0 12px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--text-body);
font-weight:700;
cursor:pointer;
}
.ps-tabs button.is-active,
.ps-segments button.is-active{
border-color:var(--accent);
color:var(--accent-deep);
background:var(--accent-tint);
}
.ps-edit-panel{
padding:0;
overflow:hidden;
}
.ps-guidance{
display:grid;
grid-template-columns:minmax(0,.95fr) minmax(260px,.75fr);
gap:16px;
padding:16px;
border-bottom:1px solid var(--hair);
background:var(--bg-surface-2);
}
.ps-guidance b{
display:block;
margin-top:5px;
color:var(--text-strong);
line-height:1.35;
}
.ps-guidance p{
margin:6px 0 0;
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.5;
}
.ps-guidance ul{
display:grid;
gap:7px;
margin:0;
padding:0;
list-style:none;
}
.ps-guidance li{
display:grid;
grid-template-columns:8px minmax(0,1fr);
align-items:start;
gap:8px;
color:var(--text-body);
font-size:var(--fs-xs);
line-height:1.45;
}
.ps-guidance li::before{
content:"";
width:6px;
height:6px;
margin-top:.55em;
border-radius:50%;
background:var(--accent);
}
.ps-form-grid{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:14px;
padding:16px;
}
.ps-number-grid{
display:grid;
grid-template-columns:repeat(4,minmax(0,1fr));
gap:10px;
padding:16px;
}
.ps-field,
.ps-numfield{
display:grid;
gap:7px;
min-width:0;
}
.ps-list-field{
display:grid;
gap:9px;
min-width:0;
}
.ps-field--wide{
grid-column:1 / -1;
}
.ps-field span,
.ps-numfield span,
.ps-list-field__head span{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:760;
}
.ps-list-field__head{
display:flex;
align-items:center;
justify-content:space-between;
gap:10px;
min-width:0;
}
.ps-list-field__rows{
display:grid;
gap:8px;
}
.ps-list-item{
display:grid;
grid-template-columns:minmax(0,1fr) 34px;
align-items:start;
gap:8px;
}
.ps-field input,
.ps-field select,
.ps-field textarea,
.ps-list-item textarea,
.ps-numfield input{
width:100%;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--text-body);
font:inherit;
}
.ps-field input,
.ps-field select,
.ps-numfield input{
min-height:38px;
padding:0 10px;
}
.ps-field textarea,
.ps-list-item textarea{
min-height:92px;
resize:vertical;
padding:10px;
line-height:1.55;
}
.ps-list-item textarea{
min-height:44px;
}
.ps-list-item__remove{
display:grid;
place-items:center;
width:34px;
height:34px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
color:var(--text-muted);
cursor:pointer;
}
.ps-list-item__remove:hover{
color:var(--danger);
border-color:var(--danger);
}
.ps-field input:focus,
.ps-field select:focus,
.ps-field textarea:focus,
.ps-list-item textarea:focus,
.ps-numfield input:focus{
outline:2px solid var(--accent);
outline-offset:1px;
border-color:var(--accent);
}
.ps-prompt{
padding:16px;
display:grid;
gap:12px;
}
.ps-prompt-section{
display:grid;
gap:10px;
padding:14px;
border:1px solid var(--border-subtle);
border-radius:var(--radius-sm);
background:var(--bg-surface);
}
.ps-prompt-section__head{
display:grid;
gap:4px;
}
.ps-prompt-section__head h3{
margin:0;
color:var(--text-heading);
font-size:var(--fs-md);
line-height:1.25;
}
.ps-prompt-section__head p,
.ps-prompt-body,
.ps-prompt-empty{
margin:0;
color:var(--text-muted);
font-size:var(--fs-sm);
line-height:1.55;
}
.ps-prompt-rows{
display:grid;
gap:7px;
margin:0;
}
.ps-prompt-row{
display:grid;
grid-template-columns:minmax(110px,0.34fr) minmax(0,1fr);
gap:10px;
align-items:start;
padding:8px 0;
border-top:1px solid var(--border-faint);
}
.ps-prompt-row dt{
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:760;
}
.ps-prompt-row dd{
margin:0;
color:var(--text-body);
font-size:var(--fs-sm);
line-height:1.55;
white-space:pre-wrap;
overflow-wrap:anywhere;
}
.ps-prompt-list{
display:grid;
gap:6px;
margin:0;
padding-left:18px;
color:var(--text-body);
font-size:var(--fs-sm);
line-height:1.5;
}
.ps-prompt-list li{
overflow-wrap:anywhere;
}
.ps-actions{
display:flex;
justify-content:flex-end;
gap:8px;
}
.ps-status{
margin:0;
padding:10px 12px;
border:1px solid var(--accent);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-sm);
font-weight:700;
}
.ps-status.is-error{
border-color:var(--crit-solid);
color:var(--crit-text);
background:var(--crit-tint);
}
.ps-check-row{
display:grid;
grid-template-columns:12px minmax(0,1fr);
gap:8px;
align-items:center;
min-height:20px;
font-size:var(--fs-xs);
line-height:1.45;
color:var(--text-body);
}
.ps-check-row .vg-dot{
justify-self:center;
align-self:center;
}
.ps-check-row--stack{
align-items:start;
}
.ps-check-row--stack .vg-dot{
margin-top:5px;
}
.ps-check-row--stack span{
display:grid;
gap:2px;
}
.ps-check-row--stack b{
color:var(--text-strong);
font-size:var(--fs-xs);
}
.ps-check-row--stack small{
color:var(--text-muted);
font-size:11px;
overflow-wrap:anywhere;
}
.ps-evidence{
display:grid;
gap:5px;
padding:9px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
color:var(--text-muted);
font-size:11px;
line-height:1.45;
}
.ps-evidence b{
color:var(--accent-deep);
font-family:var(--font-num);
}
.ps-review{
display:grid;
gap:8px;
padding:10px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:var(--bg-surface);
}
.ps-review > div:first-child{
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
}
.ps-review__actions{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:6px;
}
.ps-review__actions .vg-btn{
width:100%;
}
.ps-empty{
display:grid;
gap:4px;
padding:14px;
border:1px dashed var(--border-subtle);
border-radius:var(--radius);
color:var(--text-muted);
background:var(--bg-surface-2);
}
.ps-empty b{
color:var(--text-strong);
}
@media (max-width:1180px){
.ps-layout{
grid-template-columns:minmax(210px,260px) minmax(0,1fr);
}
.ps-inspector{
position:static;
grid-column:1 / -1;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.ps-source-grid{
grid-template-columns:1fr;
}
.ps-source-panel__actions{
display:grid;
}
}
@media (max-width:860px){
.ps-head{
display:grid;
}
.ps-head__actions{
justify-content:start;
}
.ps-layout{
grid-template-columns:1fr;
}
.ps-rail{
position:static;
}
.ps-inspector{
grid-template-columns:1fr;
}
.ps-form-grid,
.ps-number-grid{
grid-template-columns:1fr;
}
.ps-guidance,
.ps-upload-card{
grid-template-columns:1fr;
}
}

View file

@ -0,0 +1,914 @@
.pf-root{
max-width:1280px;
margin:0 auto;
display:flex;
flex-direction:column;
gap:16px;
}
.pf-head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:var(--sp-4);
flex-wrap:wrap;
}
.pf-head__actions{
display:flex;
align-items:center;
justify-content:flex-end;
gap:8px;
flex-wrap:wrap;
}
.pf-head h1{
margin:6px 0 0;
color:var(--text-strong);
font-size:var(--fs-h2);
line-height:1.28;
letter-spacing:0;
}
.pf-head p{
margin:6px 0 0;
max-width:760px;
color:var(--text-body);
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-signal-strip{
order:1;
display:grid;
grid-template-columns:1fr;
gap:0;
min-width:0;
}
.pf-triage{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
align-items:center;
gap:12px;
min-width:0;
padding:14px 16px;
border:1px solid var(--hair);
border-radius:var(--radius);
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-triage.is-active{
background:color-mix(in srgb,var(--accent-tint) 42%,var(--bg-surface));
}
.pf-triage__copy{
min-width:0;
}
.pf-triage__copy .vg-kicker{
margin-bottom:4px;
}
.pf-triage__copy b{
display:block;
color:var(--text-strong);
font-size:var(--fs-body);
line-height:1.35;
}
.pf-triage__copy span,
.pf-triage__meta small{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-triage__meta{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:0;
min-width:180px;
border:1px solid var(--border-subtle);
border-radius:var(--radius);
background:color-mix(in srgb,var(--bg-surface) 86%,transparent);
overflow:hidden;
}
.pf-triage__meta span{
display:grid;
align-content:center;
gap:2px;
padding:9px 12px;
}
.pf-triage__meta span + span{
border-left:1px solid var(--hair);
}
.pf-triage__meta b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:17px;
line-height:1;
}
.pf-error{
display:flex;
align-items:center;
gap:10px;
padding:12px 14px;
border-radius:var(--radius);
background:var(--crit-tint);
color:var(--crit-text);
font-size:var(--fs-sm);
}
.pf-kpis{
display:grid;
grid-template-columns:repeat(6,minmax(0,1fr));
border:1px solid var(--hair);
border-radius:var(--radius);
overflow:hidden;
background:var(--bg-surface);
box-shadow:var(--shadow-sm);
}
.pf-kpi{
position:relative;
min-width:0;
padding:14px 16px;
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:4px 10px;
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-kpi--primary,
.pf-kpi--warn{
background:color-mix(in srgb,var(--accent-tint) 32%,var(--bg-surface));
}
.pf-kpi--warn{
background:color-mix(in srgb,var(--warn-tint) 36%,var(--bg-surface));
}
.pf-kpi--primary .pf-kpi__ic{
color:var(--text-on-accent);
background:var(--accent);
}
.pf-kpi--warn .pf-kpi__ic{
color:var(--warn-text);
background:var(--warn-tint);
}
.pf-kpi__ic{
grid-column:2;
grid-row:1 / span 3;
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--accent);
background:var(--accent-tint);
}
.pf-kpi__lab{
display:block;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
}
.pf-kpi b{
display:block;
color:var(--text-strong);
font-family:var(--font-num);
font-size:24px;
line-height:1;
}
.pf-kpi small{
color:var(--text-muted);
font-size:12px;
line-height:1.35;
}
.pf-workspace{
order:2;
display:grid;
grid-template-columns:minmax(320px,390px) minmax(0,1fr);
align-items:start;
gap:14px;
min-width:0;
}
.pf-queue-stack{
display:flex;
flex-direction:column;
gap:14px;
min-width:0;
}
.pf-section{
display:flex;
flex-direction:column;
gap:10px;
min-width:0;
}
.pf-section__head{
display:flex;
align-items:flex-end;
justify-content:space-between;
gap:12px;
}
.pf-section__head h2{
margin:4px 0 0;
color:var(--text-strong);
font-size:var(--fs-body);
font-weight:700;
line-height:1.3;
letter-spacing:0;
}
.pf-panel{
padding:0;
overflow:hidden;
}
.pf-studio-card{
display:grid;
grid-template-columns:minmax(0,1fr) auto;
gap:14px;
align-items:center;
padding:14px;
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-studio-card__copy{
min-width:0;
display:grid;
gap:7px;
}
.pf-studio-card__copy b{
color:var(--text-strong);
font-size:var(--fs-body);
}
.pf-studio-card__copy p{
margin:0;
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.55;
}
.pf-studio-card__meta{
display:flex;
flex-wrap:wrap;
gap:6px;
}
.pf-studio-card__meta span{
padding:4px 7px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
}
.pf-studio-card__actions{
display:flex;
justify-content:flex-end;
}
.pf-section--growth{
order:3;
min-width:0;
}
.pf-growth-panel{
background:linear-gradient(180deg,var(--bg-surface),color-mix(in srgb,var(--accent-tint) 18%,var(--bg-surface)));
}
.pf-growth-list{
max-height:min(420px,44vh);
overflow:auto;
scrollbar-gutter:stable;
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:12px;
padding:12px;
}
.pf-growth-card{
min-width:0;
display:flex;
flex-direction:column;
gap:12px;
padding:13px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface) 82%,var(--bg-surface-2));
box-shadow:none;
}
.pf-growth-card__top{
display:flex;
align-items:flex-start;
justify-content:space-between;
gap:10px;
min-width:0;
}
.pf-growth-card__id{
min-width:0;
display:grid;
gap:3px;
}
.pf-growth-card__id b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__id span,
.pf-growth-card__metrics small,
.pf-growth-point span,
.pf-recent__review-state{
color:var(--text-muted);
font-size:12px;
line-height:1.4;
}
.pf-recent__review-state{
display:block;
margin-top:3px;
line-height:1.2;
}
.pf-growth-card__metrics{
display:grid;
grid-template-columns:repeat(3,minmax(0,1fr));
gap:8px;
}
.pf-growth-card__metrics span{
min-width:0;
display:grid;
gap:3px;
padding:9px 10px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:var(--bg-surface-2);
}
.pf-growth-card__metrics b{
color:var(--text-strong);
font-family:var(--font-num);
font-size:15px;
line-height:1.15;
white-space:nowrap;
}
.pf-growth-bars{
height:82px;
display:flex;
align-items:flex-end;
gap:6px;
padding:8px 8px 6px;
border:1px solid var(--paper-2);
border-radius:var(--radius-sm);
background:color-mix(in srgb,var(--bg-surface-2) 82%,transparent);
}
.pf-growth-bar{
flex:1 1 0;
min-width:14px;
height:100%;
display:grid;
grid-template-rows:minmax(0,1fr) 14px;
gap:4px;
align-items:end;
}
.pf-growth-bar i{
display:block;
width:100%;
min-height:6px;
border-radius:6px 6px 3px 3px;
background:linear-gradient(180deg,var(--accent),var(--accent-deep));
}
.pf-growth-bar.is-empty i{
background:repeating-linear-gradient(135deg,var(--paper-2),var(--paper-2) 3px,var(--hair) 3px,var(--hair) 6px);
}
.pf-growth-bar small{
color:var(--text-muted);
font-family:var(--font-num);
font-size:10px;
text-align:center;
line-height:1;
}
.pf-growth-card__tags{
min-height:26px;
display:flex;
flex-wrap:wrap;
gap:6px;
align-content:flex-start;
}
.pf-growth-card__tags span{
max-width:100%;
padding:4px 7px;
border:1px solid var(--paper-2);
border-radius:999px;
color:var(--text-body);
background:var(--bg-surface-2);
font-size:11px;
line-height:1.2;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-card__points{
display:grid;
gap:7px;
}
.pf-growth-point{
min-width:0;
display:grid;
grid-template-columns:minmax(86px,.5fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-growth-point b{
color:var(--text-strong);
font-size:12px;
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-growth-point span{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-empty{
min-height:118px;
display:grid;
place-items:center;
gap:6px;
padding:var(--sp-5);
text-align:center;
}
.pf-empty b{
color:var(--text-strong);
font-size:var(--fs-body);
}
.pf-empty span{
color:var(--text-muted);
font-size:var(--fs-xs);
}
.pf-list{
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-personas{
max-height:min(320px,42vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-persona{
display:grid;
grid-template-columns:minmax(0,1fr);
gap:8px;
align-items:start;
padding:12px;
border-top:1px solid var(--paper-2);
}
.pf-persona:first-child{border-top:0;}
.pf-persona__main{
min-width:0;
display:grid;
grid-template-columns:40px minmax(0,1fr);
gap:10px;
align-items:center;
}
.pf-persona__code{
width:40px;
height:32px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--accent-deep);
background:var(--accent-tint);
font-family:var(--font-num);
font-weight:800;
font-size:12px;
}
.pf-persona__main b{
display:block;
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__main span,
.pf-persona p,
.pf-persona__meta span{
color:var(--text-muted);
font-size:var(--fs-xs);
line-height:1.45;
}
.pf-persona p{
margin:0;
min-width:0;
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-persona__meta{
display:flex;
align-items:center;
gap:10px;
justify-content:space-between;
white-space:nowrap;
}
.pf-persona__actions{
display:flex;
justify-content:flex-end;
gap:8px;
min-width:0;
}
.pf-persona__actions .vg-btn{
min-width:72px;
padding-inline:10px;
}
.pf-alerts{
max-height:min(300px,38vh);
overflow:auto;
scrollbar-gutter:stable;
display:flex;
flex-direction:column;
}
.pf-alert{
display:grid;
grid-template-columns:auto minmax(0,1fr) auto;
gap:10px;
align-items:center;
padding:12px;
border-top:1px solid var(--paper-2);
background:color-mix(in srgb,var(--warn-tint) 34%,transparent);
}
.pf-alert:first-child{border-top:0;}
.pf-alert__ic{
width:30px;
height:30px;
display:grid;
place-items:center;
border-radius:var(--radius);
color:var(--warn-text);
background:var(--warn-tint);
}
.pf-alert__main{
min-width:0;
display:grid;
gap:3px;
}
.pf-alert__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.pf-alert__main span,
.pf-alert__main code,
.pf-alert__resource span{
color:var(--text-muted);
font-size:12px;
}
.pf-alert__main code{
overflow:hidden;
text-overflow:ellipsis;
white-space:nowrap;
}
.pf-alert__resource{
display:grid;
gap:2px;
justify-items:end;
min-width:86px;
}
.pf-alert__resource b{
color:var(--warn-text);
font-family:var(--font-num);
font-size:18px;
}
.pf-session{
width:100%;
font:inherit;
text-align:left;
background:transparent;
color:inherit;
display:grid;
grid-template-columns:8px minmax(0,1fr) auto;
gap:8px 10px;
align-items:start;
padding:12px;
border:0;
border-top:1px solid var(--paper-2);
cursor:default;
}
.pf-session:first-child{border-top:0;}
.pf-session--action{
cursor:pointer;
}
.pf-session--action:hover{
background:var(--bg-surface-2);
}
.pf-session--action:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-session__dot{
width:8px;
height:8px;
border-radius:50%;
background:var(--accent);
}
.pf-session__main{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-session__main b{
color:var(--text-strong);
font-size:var(--fs-sm);
}
.pf-session__main span,.pf-session__main code{
color:var(--text-muted);
font-size:var(--fs-xs);
overflow-wrap:anywhere;
}
.pf-session__main code,.pf-recent__learner code{
font-family:var(--font-num);
overflow:hidden;
text-overflow:ellipsis;
}
.pf-session__meta{
grid-column:2 / 4;
display:flex;
align-items:center;
justify-content:space-between;
gap:8px;
color:var(--text-muted);
font-family:var(--font-num);
font-size:var(--fs-xs);
white-space:normal;
}
.pf-session__open{
grid-column:3;
grid-row:1;
display:inline-flex;
align-items:center;
gap:5px;
align-self:center;
justify-self:end;
min-height:30px;
padding:0 9px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-session--action:hover .pf-session__open{
border-color:var(--accent);
}
.pf-recent-list{
max-height:min(620px,calc(100vh - 220px));
overflow:auto;
scrollbar-gutter:stable;
min-width:0;
}
.pf-recent-head,
.pf-recent-row{
display:grid;
grid-template-columns:
minmax(128px,1.25fr)
minmax(58px,.55fr)
minmax(66px,.55fr)
minmax(82px,.85fr)
minmax(32px,.35fr)
minmax(70px,.6fr)
minmax(66px,.55fr)
minmax(86px,.65fr);
align-items:center;
gap:8px;
min-width:0;
}
.pf-recent-head{
position:sticky;
top:0;
z-index:1;
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
padding:9px 12px;
border-bottom:1px solid var(--hair);
background:var(--bg-surface);
white-space:nowrap;
}
.pf-recent-row{
width:100%;
font:inherit;
text-align:left;
color:inherit;
background:transparent;
padding:10px 12px;
border:0;
border-top:1px solid var(--paper-2);
}
.pf-recent-row--action{
cursor:pointer;
}
.pf-recent-row--action:hover{
background:var(--bg-surface-2);
}
.pf-recent-row--action:focus-visible{
outline:2px solid var(--accent);
outline-offset:-2px;
}
.pf-recent-head + .pf-recent-row{
border-top:0;
}
.pf-recent__learner,
.pf-recent__cell{
min-width:0;
color:var(--text-body);
font-size:var(--fs-sm);
overflow-wrap:anywhere;
}
.pf-recent__cell::before{
display:none;
}
.pf-recent__learner{
min-width:0;
display:flex;
flex-direction:column;
gap:3px;
}
.pf-recent__learner b{
color:var(--text-strong);
font-size:var(--fs-sm);
line-height:1.35;
overflow-wrap:anywhere;
}
.pf-recent__learner code{
max-width:100%;
color:var(--text-muted);
font-size:11px;
white-space:nowrap;
overflow:hidden;
text-overflow:ellipsis;
}
.pf-recent__cell .vg-badge{
justify-self:start;
}
.pf-recent__cell--open{
display:flex;
justify-content:flex-end;
}
.pf-recent__open{
display:inline-flex;
align-items:center;
justify-content:center;
gap:4px;
min-height:30px;
padding:0 7px;
border:1px solid var(--hair);
border-radius:var(--radius-sm);
color:var(--accent-deep);
background:var(--accent-tint);
font-size:var(--fs-xs);
font-weight:760;
white-space:nowrap;
}
.pf-recent-row--action:hover .pf-recent__open{
border-color:var(--accent);
}
@media (max-width:1100px){
.pf-signal-strip,
.pf-workspace{
grid-template-columns:1fr;
}
.pf-triage{
grid-template-columns:auto minmax(0,max-content) auto;
justify-content:start;
}
.pf-kpis{
grid-template-columns:repeat(4,minmax(0,1fr));
}
.pf-kpi:nth-child(n+2){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+4){border-top:0;}
.pf-growth-list{
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-list,
.pf-personas{
max-height:360px;
}
.pf-recent-list{
max-height:460px;
}
}
@media (max-width:860px){
.pf-head__actions{
width:100%;
justify-content:flex-start;
}
.pf-triage{
grid-template-columns:auto minmax(0,1fr);
}
.pf-triage__meta{
grid-column:1 / -1;
width:100%;
}
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi:nth-child(n+2){border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-growth-list{
grid-template-columns:1fr;
}
.pf-recent-list{
display:flex;
flex-direction:column;
gap:10px;
padding:10px;
background:var(--bg-surface-2);
overflow-x:hidden;
}
.pf-recent-head{
display:none;
}
.pf-recent-row{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
gap:10px 12px;
padding:12px;
border:1px solid var(--paper-2);
border-radius:var(--radius);
background:var(--bg-surface);
}
.pf-recent__learner{
grid-column:1 / -1;
padding-bottom:10px;
border-bottom:1px solid var(--paper-2);
}
.pf-recent__learner code{
white-space:normal;
overflow-wrap:anywhere;
}
.pf-recent__cell{
display:grid;
grid-template-columns:minmax(74px,.4fr) minmax(0,1fr);
gap:8px;
align-items:center;
}
.pf-recent__cell--open{
justify-content:stretch;
}
.pf-recent__cell::before{
display:block;
content:attr(data-label);
color:var(--text-muted);
font-size:var(--fs-xs);
font-weight:700;
line-height:1.35;
}
.pf-session{
grid-template-columns:8px minmax(0,1fr);
}
.pf-session__meta{
grid-column:2;
justify-content:flex-start;
}
.pf-session__open{
grid-column:2;
grid-row:auto;
justify-self:start;
}
}
@media (max-width:520px){
.pf-kpis{grid-template-columns:repeat(2,minmax(0,1fr));}
.pf-kpi,
.pf-kpi + .pf-kpi{border-left:0;}
.pf-kpi:nth-child(even){border-left:1px solid var(--hair);}
.pf-kpi:nth-child(n+3){border-top:1px solid var(--hair);}
.pf-persona__actions{
display:grid;
grid-template-columns:repeat(2,minmax(0,1fr));
}
.pf-persona__actions .vg-btn{
width:100%;
}
.pf-growth-list{
padding:10px;
}
.pf-growth-card__metrics{
grid-template-columns:1fr;
}
.pf-growth-point{
grid-template-columns:1fr;
gap:2px;
}
.pf-growth-point b,
.pf-growth-point span{
white-space:normal;
}
.pf-alert{
grid-template-columns:auto minmax(0,1fr);
}
.pf-alert__resource{
grid-column:2;
justify-items:start;
}
.pf-recent-list{
padding:8px;
}
.pf-recent-row{
grid-template-columns:1fr;
gap:10px;
}
.pf-recent__cell{
grid-template-columns:minmax(64px,.32fr) minmax(0,1fr);
}
.pf-recent__cell--open .pf-recent__open{
justify-self:start;
}
}

View file

@ -0,0 +1,74 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "vignette.pii_masking_eval_input.v1",
"title": "Vignette PII masking evaluation input",
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"locale",
"source",
"category",
"severity",
"text",
"expected_entities",
"forbidden_substrings"
],
"properties": {
"id": { "type": "string", "minLength": 1 },
"locale": { "type": "string", "default": "ko-KR" },
"source": {
"type": "string",
"enum": ["synthetic", "operator_supplied_redacted", "operational_gold"],
"default": "synthetic"
},
"category": {
"type": "string",
"enum": [
"name",
"organization",
"contact",
"national_id",
"quasi_identifier",
"negative_control",
"unspecified"
],
"default": "unspecified"
},
"severity": {
"type": "string",
"enum": ["low", "medium", "high", "critical"],
"default": "medium"
},
"text": {
"type": "string",
"minLength": 1,
"description": "Evaluation input text. Use only local synthetic or approved redacted/gold text according to data governance gates."
},
"expected_entities": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true,
"default": []
},
"unexpected_entities": {
"type": "array",
"items": { "type": "string" },
"uniqueItems": true,
"default": []
},
"forbidden_substrings": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"required_substrings": {
"type": "array",
"items": { "type": "string" },
"default": []
}
}
}
}

View file

@ -0,0 +1,99 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "vignette.pii_masking_eval_report.v1",
"title": "Vignette PII masking evaluation report",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"input_schema_version",
"run_mode",
"data_source",
"evidence_text_included",
"passed",
"cases_total",
"cases_passed",
"cases_failed",
"expected_entity_recall",
"forbidden_substring_removal",
"unexpected_entity_violations",
"by_source",
"by_category",
"by_severity",
"results"
],
"properties": {
"schema_version": { "const": "vignette.pii_masking_eval_report.v1" },
"input_schema_version": { "const": "vignette.pii_masking_eval_input.v1" },
"run_mode": { "const": "technical_dry_run" },
"data_source": { "type": "string" },
"evidence_text_included": { "type": "boolean" },
"passed": { "type": "boolean" },
"cases_total": { "type": "integer", "minimum": 0 },
"cases_passed": { "type": "integer", "minimum": 0 },
"cases_failed": { "type": "integer", "minimum": 0 },
"expected_entity_recall": { "type": "number", "minimum": 0, "maximum": 1 },
"forbidden_substring_removal": { "type": "number", "minimum": 0, "maximum": 1 },
"unexpected_entity_violations": { "type": "integer", "minimum": 0 },
"by_source": { "$ref": "#/$defs/breakdown" },
"by_category": { "$ref": "#/$defs/breakdown" },
"by_severity": { "$ref": "#/$defs/breakdown" },
"results": {
"type": "array",
"items": {
"type": "object",
"additionalProperties": false,
"required": [
"id",
"locale",
"source",
"category",
"severity",
"passed",
"entities",
"missing_entities",
"unexpected_entities",
"forbidden_remaining_count",
"required_missing"
],
"properties": {
"id": { "type": "string" },
"locale": { "type": "string" },
"source": { "type": "string" },
"category": { "type": "string" },
"severity": { "type": "string" },
"passed": { "type": "boolean" },
"entities": { "type": "array", "items": { "type": "string" } },
"masked_text": {
"type": "string",
"description": "Optional local synthetic debugging evidence. Do not include this field in operational reports."
},
"forbidden_remaining_count": { "type": "integer", "minimum": 0 },
"missing_entities": { "type": "array", "items": { "type": "string" } },
"unexpected_entities": { "type": "array", "items": { "type": "string" } },
"forbidden_remaining": {
"type": "array",
"items": { "type": "string" },
"description": "Optional local synthetic debugging evidence. Do not include this field in operational reports."
},
"required_missing": { "type": "array", "items": { "type": "string" } }
}
}
}
},
"$defs": {
"breakdown": {
"type": "object",
"additionalProperties": {
"type": "object",
"additionalProperties": false,
"required": ["cases_total", "cases_passed", "cases_failed"],
"properties": {
"cases_total": { "type": "integer", "minimum": 0 },
"cases_passed": { "type": "integer", "minimum": 0 },
"cases_failed": { "type": "integer", "minimum": 0 }
}
}
}
}
}

View file

@ -1,6 +1,10 @@
[
{
"id": "ko_name_label",
"locale": "ko-KR",
"source": "synthetic",
"category": "name",
"severity": "high",
"text": "이름: 김서연, 연락은 하지 말아 주세요.",
"expected_entities": ["NAME"],
"forbidden_substrings": ["김서연"],
@ -8,13 +12,65 @@
},
{
"id": "ko_name_relationship_context",
"locale": "ko-KR",
"source": "synthetic",
"category": "name",
"severity": "high",
"text": "친구 박민수에게 오늘 상담 내용을 말하지 않았어요.",
"expected_entities": ["NAME"],
"forbidden_substrings": ["박민수"],
"required_substrings": ["친구 [NAME]에게"]
},
{
"id": "ko_name_role_particle",
"locale": "ko-KR",
"source": "synthetic",
"category": "name",
"severity": "high",
"text": "상담자 최하늘은 이전 상담 기록을 보지 않았습니다.",
"expected_entities": ["NAME"],
"forbidden_substrings": ["최하늘"],
"required_substrings": ["상담자 [NAME]은"]
},
{
"id": "ko_name_natural_label_self",
"locale": "ko-KR",
"source": "synthetic",
"category": "name",
"severity": "high",
"text": "제 이름은 김서연입니다.",
"expected_entities": ["NAME"],
"forbidden_substrings": ["김서연"],
"required_substrings": ["제 이름은 [NAME]입니다"]
},
{
"id": "ko_name_natural_label_guardian",
"locale": "ko-KR",
"source": "synthetic",
"category": "name",
"severity": "high",
"text": "보호자 이름은 박민수입니다.",
"expected_entities": ["NAME"],
"forbidden_substrings": ["박민수"],
"required_substrings": ["보호자 이름은 [NAME]입니다"]
},
{
"id": "ko_name_self_intro_copula",
"locale": "ko-KR",
"source": "synthetic",
"category": "name",
"severity": "high",
"text": "저는 최하늘입니다.",
"expected_entities": ["NAME"],
"forbidden_substrings": ["최하늘"],
"required_substrings": ["저는 [NAME]입니다"]
},
{
"id": "ko_org_school_department",
"locale": "ko-KR",
"source": "synthetic",
"category": "organization",
"severity": "high",
"text": "소속은 한신대학교 상담심리학과입니다.",
"expected_entities": ["ORG"],
"unexpected_entities": ["NAME"],
@ -23,16 +79,94 @@
},
{
"id": "ko_org_health_center",
"locale": "ko-KR",
"source": "synthetic",
"category": "organization",
"severity": "high",
"text": "지난주 마음봄상담센터에서 안내를 받았습니다.",
"expected_entities": ["ORG"],
"unexpected_entities": ["NAME"],
"forbidden_substrings": ["마음봄상담센터"],
"required_substrings": ["[ORG]"]
},
{
"id": "ko_org_hospital",
"locale": "ko-KR",
"source": "synthetic",
"category": "organization",
"severity": "high",
"text": "새봄병원에서 진료를 받았다고 말했어요.",
"expected_entities": ["ORG"],
"forbidden_substrings": ["새봄병원"],
"required_substrings": ["[ORG]"]
},
{
"id": "ko_contact_phone_email",
"locale": "ko-KR",
"source": "synthetic",
"category": "contact",
"severity": "critical",
"text": "연락처는 010-1234-5678이고 이메일은 seoyeon@example.com입니다.",
"expected_entities": ["PHONE", "EMAIL"],
"forbidden_substrings": ["010-1234-5678", "seoyeon@example.com"],
"required_substrings": ["[PHONE]", "[EMAIL]"]
},
{
"id": "ko_rrn_numid",
"locale": "ko-KR",
"source": "synthetic",
"category": "national_id",
"severity": "critical",
"text": "주민번호는 990101-1234567이고 보호자 번호는 123456789012입니다.",
"expected_entities": ["RRN", "NUMID"],
"forbidden_substrings": ["990101-1234567", "123456789012"],
"required_substrings": ["[RRN]", "[NUMID]"]
},
{
"id": "ko_date_money_address",
"locale": "ko-KR",
"source": "synthetic",
"category": "quasi_identifier",
"severity": "medium",
"text": "2001년 4월 18일 서울시 강남구 역삼동에서 1200원을 냈어요.",
"expected_entities": ["DATE", "ADDR", "MONEY"],
"forbidden_substrings": ["2001년 4월 18일", "서울시 강남구 역삼동", "1200원"],
"required_substrings": ["[DATE]", "[ADDR]", "[MONEY]"]
},
{
"id": "ko_common_words_false_positive",
"locale": "ko-KR",
"source": "synthetic",
"category": "negative_control",
"severity": "medium",
"text": "학교 가는 게 힘들고 엄마랑 친구 이야기를 하면 불안해요.",
"expected_entities": [],
"unexpected_entities": ["NAME", "ORG"],
"forbidden_substrings": [],
"required_substrings": ["학교", "엄마", "친구"]
},
{
"id": "ko_address_negative_control",
"locale": "ko-KR",
"source": "synthetic",
"category": "negative_control",
"severity": "medium",
"text": "서울 가는 길 이야기를 하면 그냥 마음이 답답해요.",
"expected_entities": [],
"unexpected_entities": ["ADDR"],
"forbidden_substrings": [],
"required_substrings": ["서울 가는 길"]
},
{
"id": "ko_name_label_negative_control",
"locale": "ko-KR",
"source": "synthetic",
"category": "negative_control",
"severity": "medium",
"text": "이름은 중요하지 않고 상담 내용만 이야기하고 싶어요.",
"expected_entities": [],
"unexpected_entities": ["NAME"],
"forbidden_substrings": [],
"required_substrings": ["이름은 중요하지"]
}
]

View file

@ -0,0 +1,73 @@
{
"schema_version": "vignette.case_worksheet_rubric.v1",
"rubric_id": "case-worksheet-rubric@scaffold-2026-06-28",
"title": "Case formulation worksheet rubric scaffold",
"status": "scaffold_only",
"content_owner": "clinical_team",
"scoring_enabled": false,
"approval": null,
"notes": [
"This file is a machine-readable scaffold only.",
"Clinical scoring criteria, score anchors, and approval metadata must be supplied by the clinical team before scoring is enabled."
],
"sections": [
{
"key": "exploration_11",
"title": "탐색 11항목",
"items": [
{ "key": "presenting_complaint", "label": "주호소", "criteria": [], "score_scale": null },
{ "key": "trigger_context", "label": "계기·상황", "criteria": [], "score_scale": null },
{ "key": "emotion", "label": "정서", "criteria": [], "score_scale": null },
{ "key": "cognition", "label": "생각", "criteria": [], "score_scale": null },
{ "key": "behavior", "label": "행동", "criteria": [], "score_scale": null },
{ "key": "body", "label": "신체·수면", "criteria": [], "score_scale": null },
{ "key": "relationship", "label": "관계", "criteria": [], "score_scale": null },
{ "key": "resources", "label": "자원", "criteria": [], "score_scale": null },
{ "key": "risk", "label": "위험 신호", "criteria": [], "score_scale": null },
{ "key": "motivation", "label": "변화동기", "criteria": [], "score_scale": null },
{ "key": "first_goal", "label": "상담 목표 초안", "criteria": [], "score_scale": null }
]
},
{
"key": "five_domains",
"title": "호소 5영역",
"items": [
{ "key": "domain_emotion", "label": "정서", "criteria": [], "score_scale": null },
{ "key": "domain_cognition", "label": "인지", "criteria": [], "score_scale": null },
{ "key": "domain_behavior", "label": "행동", "criteria": [], "score_scale": null },
{ "key": "domain_relationship", "label": "대인관계", "criteria": [], "score_scale": null },
{ "key": "domain_body", "label": "신체", "criteria": [], "score_scale": null }
]
},
{
"key": "cognitive_triad_emotions",
"title": "인지삼제·1/2차 감정",
"items": [
{ "key": "triad_self", "label": "자기", "criteria": [], "score_scale": null },
{ "key": "triad_world", "label": "타인·세계", "criteria": [], "score_scale": null },
{ "key": "triad_future", "label": "미래", "criteria": [], "score_scale": null },
{ "key": "primary_emotion", "label": "1차 감정", "criteria": [], "score_scale": null },
{ "key": "secondary_emotion", "label": "2차 감정", "criteria": [], "score_scale": null }
]
},
{
"key": "protective_barrier_quadrants",
"title": "보호·방해 4사분면",
"items": [
{ "key": "internal_protective", "label": "내적 보호요인", "criteria": [], "score_scale": null },
{ "key": "internal_barrier", "label": "내적 방해요인", "criteria": [], "score_scale": null },
{ "key": "external_protective", "label": "외적 보호요인", "criteria": [], "score_scale": null },
{ "key": "external_barrier", "label": "외적 방해요인", "criteria": [], "score_scale": null }
]
},
{
"key": "biopsychosocial_goals",
"title": "생물·심리·사회 목표",
"items": [
{ "key": "bio_goal", "label": "생물", "criteria": [], "score_scale": null },
{ "key": "psy_goal", "label": "심리", "criteria": [], "score_scale": null },
{ "key": "social_goal", "label": "사회", "criteria": [], "score_scale": null }
]
}
]
}

View file

@ -0,0 +1,99 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "vignette.case_worksheet_rubric.v1",
"title": "Vignette case worksheet rubric",
"type": "object",
"additionalProperties": false,
"required": [
"schema_version",
"rubric_id",
"title",
"status",
"content_owner",
"scoring_enabled",
"approval",
"sections"
],
"properties": {
"schema_version": { "const": "vignette.case_worksheet_rubric.v1" },
"rubric_id": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"status": { "enum": ["scaffold_only", "draft", "approved"] },
"content_owner": { "const": "clinical_team" },
"scoring_enabled": { "type": "boolean" },
"approval": {
"anyOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"required": ["clinical_reviewer", "approved_at"],
"properties": {
"clinical_reviewer": { "type": "string", "minLength": 1 },
"approved_at": { "type": "string", "minLength": 1 },
"notes": { "type": "string" }
}
}
]
},
"notes": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"sections": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/section" }
}
},
"$defs": {
"section": {
"type": "object",
"additionalProperties": false,
"required": ["key", "title", "items"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"title": { "type": "string", "minLength": 1 },
"items": {
"type": "array",
"minItems": 1,
"items": { "$ref": "#/$defs/item" }
}
}
},
"item": {
"type": "object",
"additionalProperties": false,
"required": ["key", "label", "criteria", "score_scale"],
"properties": {
"key": { "type": "string", "minLength": 1 },
"label": { "type": "string", "minLength": 1 },
"criteria": {
"type": "array",
"items": { "type": "string" },
"default": []
},
"score_scale": {
"anyOf": [
{ "type": "null" },
{
"type": "object",
"additionalProperties": false,
"required": ["min", "max", "anchors"],
"properties": {
"min": { "type": "integer" },
"max": { "type": "integer" },
"anchors": {
"type": "array",
"minItems": 2,
"items": { "type": "string" }
}
}
}
]
}
}
}
}
}

View file

@ -78,6 +78,7 @@ This keeps `/personas` fail-closed semantics stable: normal catalog entries rema
- `python -B -m pytest -p no:cacheprovider app/test_session_turn_persistence.py app/test_evaluation_persistence.py app/test_session_share.py app/test_learner_dashboard.py app/test_rbac_idor.py app/test_teacher_dashboard.py -q` — 48 passed
- `python -B -m py_compile app/persona_read_model.py app/routes/personas.py`
- `python -B -m pytest -p no:cacheprovider app/test_persona_review.py -q` — 33 passed
- `python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_session_turn_persistence.py -q` — 55 passed
- `npm run check:api-types`
- `npm run typecheck`

File diff suppressed because one or more lines are too long

View file

@ -531,6 +531,15 @@ React 19 + Vite. 라우팅은 `apps/web/src/App.tsx`(react-router-dom).
`SessionReview`은 종료된 회기에서만 "공유 URL 복사" 버튼을 노출한다.
- dev 환경: `vite.config``/api``http://127.0.0.1:8000` 프록시(`/api` 프리픽스 제거).
배포 호스트(`vignette.chanpaca.net`, `*.pages.dev`)에서는 `api-vignette.chanpaca.net`을 직접 가리킨다.
- 스타일 소유권:
- 전역 토큰과 리셋은 `apps/web/src/styles/tokens.css` / `global.css`, 공용 UI primitive는
`components/ui/ui.css`, 셸은 `components/shell/shell.css`가 소유하고 `main.tsx`에서 1회 import한다.
- 화면 전용 스타일은 해당 라우트 TSX가 가까운 `.css` 파일을 import한다
(예: `pages/login/login.css`, `pages/learner-home.css`, `pages/session/session.css`).
TSX 안에 `<style>{..._CSS}</style>` template literal을 두지 않는다. 새 화면은 스타일을
페이지/기능 CSS로 분리하고, 반복 JSX는 작은 presentational component로 빼서 인증·데이터 상태 로직과 섞지 않는다.
- 로그인 진입 화면은 `pages/login/LoginBrand.tsx`, `LoginPanel.tsx`, `login.css`로 분리되어
`Login.tsx`는 OAuth/dev-login 상태와 라우팅만 소유한다.
세션 화면 흐름(요약): 학습자 발화 입력 → `sessionApi.stream(id, text)`(SSE) 또는 `turn`(동기) →
토큰 누적 표시 → 코칭 모드면 `sessionApi.liveCoach(...)` + `liveCoachHistory(...)`로 아바타 말풍선/발화별

View file

@ -45,13 +45,13 @@
## 2. 갭 로드맵 — 심각도 우선순위
심각도 분류: **critical 3 / high 4 / medium+ 6**. C1의 "구조 전무"와 저장형 제출물 부재는 2차로 해소됐고, 임상 루브릭·AI 채점·교수자 검수는 계속 추적한다. `✓`는 작성자가 코드 grep으로 직접 재확인한 항목, `(분석)`은 분석 결과로 착수 전 코드 1차 재확인 권고.
심각도 분류: **critical 3 / high 4 / medium+ 6**. C1의 "구조 전무"와 저장형 제출물 부재는 2차로 해소됐고, 3차에서는 임상팀 루브릭을 외부 JSON으로 받을 schema/loader/validation scaffold까지 추가했다. 임상 루브릭 콘텐츠·AI 채점·교수자 검수는 계속 추적한다. `✓`는 작성자가 코드 grep으로 직접 재확인한 항목, `(분석)`은 분석 결과로 착수 전 코드 1차 재확인 권고.
### Critical (3)
| ID | 갭 | 현재상태 | 권고 | 근거 |
|---|---|---|---|---|
| **C1** | 사례개념화·치료계획 산출물 저장형 구조 2차 구현·채점/검수 잔여 | `SessionReviewResponse.caseWorksheet`가 축어록 근거 기반 초안을 제공하고, 리뷰 화면에서 학습자가 편집한 워크시트를 `PUT /sessions/{id}/review/worksheet``app.case_worksheet`에 저장한다. 이후 `GET /review``saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. CCD는 숨은 정답키로 남고 저장본에 점수로 노출하지 않는다. | AI 축어록 초안 추출 고도화, 규칙 기반 채점, 교수자 검수 플로우. 루브릭은 임상팀 외부 정의 가능하게 외부화. | doc1·doc4·doc5 |
| **C1** | 사례개념화·치료계획 산출물 저장형 구조 3차 구현·루브릭 외부화 scaffold 완료·채점/검수 잔여 | `SessionReviewResponse.caseWorksheet`가 축어록 근거 기반 초안을 제공하고, 리뷰 화면에서 학습자가 편집한 워크시트를 `PUT /sessions/{id}/review/worksheet``app.case_worksheet`에 저장한다. 이후 `GET /review``saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. 3차에서는 `data/rubrics/case-worksheet-rubric.json`, Draft 2020-12 schema, `app.services.case_worksheet_rubric`, `scripts/check-case-worksheet-rubric.py`를 추가해 임상팀 확정 루브릭을 코드 수정 없이 받을 scaffold를 만들었다. 현재 rubric은 `scaffold_only`/`scoring_enabled=false`이며 서버 생성 워크시트 5개 section/28개 item key와 일치하는지만 검증한다. 템플릿 key source는 `CASE_WORKSHEET_SECTION_SPECS``case_worksheet_template_item_keys()`로 명시해 validator/CLI/test가 더미 턴 없이 같은 생성 spec을 참조한다. CCD는 숨은 정답키로 남고 저장본에 점수로 노출하지 않는다. | 임상팀 확정 루브릭 콘텐츠(level anchor, 가중치, 컷오프, 예시 답안), AI 채점 적용·calibration, 교수자 승인/반려/수정요청 검수 플로우. | doc1·doc4·doc5 |
| **C2** | 위기개입 프로토콜·생명유지서약·에스컬레이션 1차 배선 완료·임상 고도화 필요 | 실제 자해·자살 신호는 LLM/엔진 호출 전 중단하고 109 리소스와 `conversation_stopped`를 REST/SSE/voice 응답에 싣는다. `crisis.risk_level`은 상태머신 `ideation_observed`로 전달하고, escalate 시 `app.safety_events` detail 적재 및 교수자 대시보드 안전 알림 큐로 연결한다. | 남은 것은 임상팀 콘텐츠: 비밀보장 예외고지→단계적 탐색→생명유지서약 스크립트, 실시간 push/메일 알림 정책, 위기탐색 누락 시 회기리뷰 감점 루브릭. | doc1·doc5(핵심 시나리오)·doc4(IRB 전제) |
| **C3** | 이론모드 2차 배선·학습자 명시 선택 UI 완료·CBT 콘텐츠 잔여 | `TurnContext`/`prepare_turn`/sessions/voice/evaluator에 `theory_mode`가 전달되고, `build_turn_messages`도 인간중심·CBT·통합 이론 프레이밍을 엔진 메시지에 넣는다. 프론트는 `persona.theory_target` 기준 기본값을 잡되, 세션 시작 전 `humanistic`/`cbt`/`integrative` segmented control로 학습자가 명시 선택하고 `POST /sessions``theory_mode`로 전송한다. | 임상팀이 확정한 CBT 단계 프롬프트 체인, 이론부합 채점 루브릭. 이번 UI는 임상 문안·루브릭·백엔드 enum을 확장하지 않았다. | doc4(humanistic+CBT 필수)·doc2/doc5 |
@ -61,8 +61,8 @@
|---|---|---|---|---|
| **H1** | 계약 평가 KPI(자기효능감·기술숙련도·수련만족도 사전사후) 수집·입력·CSV/report 계산 1차 | `app.learner_prepost_measure`, 학습자 본인용 `GET/PUT /users/me/prepost-measures`, `SessionReview`의 파일럿 증거 원장 카드가 3척도 pre/post 1~5 aggregate evidence를 저장·조회한다. `app.services.phase3_kpi_export``scripts/export-phase3-kpi.py`는 원장 row를 Phase 3 evidence root의 `02-measures/prepost_measures.csv``02-measures/kpi_report.json` scaffold로 산출한다. participant id는 가명화하고, 3척도 paired normalized mean pre/post/delta, complete/missing pair를 계산한다. | 공식 문항 확정, 실험/통제군 배정, 추이 시각화, 통계검정 종류/alpha/결측 처리, 실제 20명 evidence와 steward/legal/IAA 검수. 현재 API/UI/export는 공식 효과성·성적·수료 판정이 아니라 파일럿 evidence 계산이다. (κ/ICC·환각률은 doc4 미명시 → 평가설계 확정.) | doc4(20명 실험/통제군·단회기 50분·3척도 pre-post) |
| **H2** | 턴별 fast-loop + 라이브 코칭 1차 가동·학습자 리뷰 2열 UI 1차 완료·골든셋 잔여 | `make_eval_hook`이 submit/voice 생성 경로에 주입되고, stream은 `_evaluate_stream_turn`으로 fast-loop 평가를 붙인다. 결과는 `feedback_scores`, `alternative_utterance` 등 정규화 테이블에 적재·hydrate된다. 추가로 `app/services/live_coach.py`, `POST/GET /sessions/{id}/live-coach`, `POST /kb/live-coach/source-packs/sync`, `app.live_coach_events`, `data/kb/live_coaching_workbook_0615.json`, `data/kb/live_coaching_sources/*.json`을 연결해 워크북·DSM·공식 지침 요약 기반 코칭 아바타 말풍선·근거 모달·발화별 이력 오버레이와 RAG 증분 색인을 제공한다. `app.services.source_pack_sync`가 repo source pack의 active `content_hash`를 비교하고 변경 시 document version을 최신+1로 올린다. 학습자 `SessionReview` 데스크톱은 좌측 축어록 타임라인, 우측 요약·감정·흐름·루브릭·강점·개선점·pre/post·워크시트·피드백 작업열의 2열 구조로 재배치했다. | 원천 축어록 few-shot 골든셋 적재, 임상팀 확정 루브릭과 source pack 임상 검수 상태 운영정책 보강. | doc2·doc5(골드 포맷) |
| **H3** | 임상팀 콘텐츠 입력 경로(페르소나 저작 CRUD) 2차 구현·원문 격리 정책 1차 완료·임상 검수 잔여 | draft 생성·조회·편집·검수요청 API와 교수 콘솔 JSON 초안 패널은 연결됐다. `persona_repository.py`는 in-code `SEED_PERSONAS`(P1~P3)와 `data/personas/P4.json`~`P7.json``PersonaCard`로 합쳐 `materialize_seed_personas()`와 seed fallback catalog에 포함한다. `scripts/materialize-persona-seeds.py`는 같은 seed manifest를 dry-run 기본으로 보고하고, `--apply`에서만 DB pool을 초기화한 뒤 기존 idempotent DB materializer를 호출한다. `scripts/sync-persona-sources.py`는 DB-backed dry-run/apply runner로 repo-managed source pack의 `content_hash`/document version을 비교한다. RAG 기반 draft 생성은 source/chunk evidence와 함께 `persona-draft-rag@2026-06-28.1` prompt bundle id/version/hash를 engine metadata 및 draft `source_provenance`에 남긴다. `POST /personas/sources`는 raw 원문 hash-only 증거를 `kb.raw_source_artifact`에 따로 기록하고, sanitized 파생본만 evaluator-only RAG chunk로 색인한다. `rag.index_document()``sensitivity=3` 또는 raw marker chunk를 DB 접근 전에 차단한다. `app/persona_read_model.py`는 catalog/review/draft/source/evidence DTO와 mapper를 route에서 분리해 OpenAPI schema 이름을 유지한다. | JSON 대신 항목형 저작 UI, 루브릭·이론 콘텐츠 외부화, P4~P7 포함 임상팀 최종 검수/서면 evidence 확보, 암호화 blob/vault 기반 원문 실저장. | doc3(R&R)·doc4(페르소나=전문가 산출물) |
| **H4** | PII 마스킹 한국어 이름/기관 로컬 1차 + fixture 평가 harness + 온보딩·동의 게이트 잔여 | Presidio `language='en'` 고정이라 한국어 이름/기관 정밀 NER 한계는 남아 있지만, 정규식 폴백에 한국어 날짜·금액·행정구역 주소와 함께 이름/성명 라벨, 성씨+이름+조사/호칭, 대학교·학과·병원·센터 등 기관 suffix 기반 로컬 휴리스틱 마스킹을 추가했다. Presidio가 설치돼도 한국어 누락을 막기 위해 fallback을 후단에 한 번 더 태운다. 상담 생성(generate/stream), fast evaluator prompt, client turn `text_masked`에서 한국어 NAME/ORG raw 값이 남지 않도록 회귀화했다. `app.services.pii_masking_eval`, `data/privacy/pii-masking-ko-fixtures.json`, `scripts/evaluate-pii-masking.py`로 합성 NAME/ORG 5케이스를 entity recall·forbidden substring removal·unexpected entity violation으로 평가하고, `소속`/`안내` NAME 오탐을 stopword로 보정했다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user`에 이름·소속·학과·학년/직위·연락처·주소/수령지·닉네임·자기소개·아바타 URL·약관/개인정보 동의 버전 필드를 추가했고, 로그인 직후 `/onboarding` 완료 전에는 역할 홈과 learner 회기 시작을 막는다. 아바타 이미지는 `/users/me/avatar`에서 MIME/시그니처/3MB 제한 후 파일 저장소에 두고 URL만 보관한다. 온보딩 저장 시 learner `consent_at`도 함께 세팅하며 auth E2E에서 신규 계정 온보딩→아바타 업로드→학습자 홈 이동을 검증했다. | 모델 기반 ko NER 정밀화, 실제 운영 말뭉치 기반 오탐/미탐 평가, 미성년/guardian 및 법무 검토가 필요한 최종 서명 동의서·개인정보 처리방침·약관 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) |
| **H3** | 임상팀 콘텐츠 입력 경로(페르소나 저작 CRUD) 2차 구현·원문 격리 정책 1차·항목형 목록 저작/프롬프트 검토 UI 완료·임상 검수 잔여 | draft 생성·조회·편집·검수요청 API와 교수 콘솔 JSON 초안 패널은 연결됐다. `persona_repository.py`는 in-code `SEED_PERSONAS`(P1~P3)와 `data/personas/P4.json`~`P7.json``PersonaCard`로 합쳐 `materialize_seed_personas()`와 seed fallback catalog에 포함한다. `scripts/materialize-persona-seeds.py`는 같은 seed manifest를 dry-run 기본으로 보고하고, `--apply`에서만 DB pool을 초기화한 뒤 기존 idempotent DB materializer를 호출한다. `scripts/sync-persona-sources.py`는 DB-backed dry-run/apply runner로 repo-managed source pack의 `content_hash`/document version을 비교한다. RAG 기반 draft 생성은 source/chunk evidence와 함께 `persona-draft-rag@2026-06-28.1` prompt bundle id/version/hash를 engine metadata 및 draft `source_provenance`에 남긴다. `POST /personas/sources`는 raw 원문 hash-only 증거를 `kb.raw_source_artifact`에 따로 기록하고, sanitized 파생본만 evaluator-only RAG chunk로 색인한다. `rag.index_document()``sensitivity=3` 또는 raw marker chunk를 DB 접근 전에 차단한다. `app/persona_read_model.py`는 catalog/review/draft/source/evidence DTO와 mapper를 route에서 분리해 OpenAPI schema 이름을 유지한다. PersonaStudio는 자동사고, 회기 시나리오, 말투 filler/verbal tic/nonverbal cue, 역린·금기 응답·금기어를 행 추가/삭제 UI로 편집하고, 저장 직전 빈 항목을 제거하되 기존 배열 schema를 유지한다. 프롬프트 탭은 raw JSON textarea 대신 L1 카드·인적 범주·임상 배경·말투·수치 파라미터·역린·회기 시나리오·추가 계약 섹션으로 같은 draft 데이터를 검토하게 한다. | 루브릭·이론 콘텐츠 외부화, P4~P7 포함 임상팀 최종 검수/서면 evidence 확보, 암호화 blob/vault 기반 원문 실저장. | doc3(R&R)·doc4(페르소나=전문가 산출물) |
| **H4** | PII 마스킹 한국어 이름/기관 로컬 1차 + 15-case fixture/schema 평가 harness + 온보딩·동의 게이트 잔여 | Presidio `language='en'` 고정이라 한국어 이름/기관 정밀 NER 한계는 남아 있지만, 정규식 폴백에 한국어 날짜·금액·행정구역 주소와 함께 이름/성명 라벨, 성씨+이름+조사/호칭, 대학교·학과·병원·센터 등 기관 suffix 기반 로컬 휴리스틱 마스킹을 추가했다. 66차에서는 `제 이름은 김서연입니다`, `보호자 이름은 박민수입니다`, `저는 최하늘입니다` 같은 자연 발화형 이름 라벨·자기소개 패턴을 추가하고 `이름은 중요하지 않다` negative control로 오탐을 막았다. Presidio가 설치돼도 한국어 누락을 막기 위해 fallback을 후단에 한 번 더 태운다. 상담 생성(generate/stream), fast evaluator prompt, client turn `text_masked`에서 한국어 NAME/ORG raw 값이 남지 않도록 회귀화했다. `app.services.pii_masking_eval`, `data/privacy/pii-masking-ko-fixtures.json`, `scripts/evaluate-pii-masking.py`로 합성 fixture 15케이스를 NAME/ORG/PHONE/EMAIL/RRN/NUMID/DATE/MONEY/ADDR/negative-control 범위에서 entity recall·forbidden substring removal·unexpected entity violation으로 평가한다. `data/privacy/pii-masking-eval-input.schema.json``data/privacy/pii-masking-eval-report.schema.json`은 source/category/severity metadata와 summary-only `technical_dry_run` 리포트 계약을 고정하며, 기본 CLI JSON은 `masked_text`/`forbidden_remaining` 원문 증거를 제외한다. `소속`/`안내`/`이름` NAME 오탐도 stopword로 보정했다. 외부 LLM 호출은 상담 생성(generate/stream)·fast/deep 평가 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency만 적재하도록 연결했고, prompt/completion 본문은 저장하지 않는다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log` 3행 증가를 확인했다. `app_user`에 이름·소속·학과·학년/직위·연락처·주소/수령지·닉네임·자기소개·아바타 URL·약관/개인정보 동의 버전 필드를 추가했고, 로그인 직후 `/onboarding` 완료 전에는 역할 홈과 learner 회기 시작을 막는다. 아바타 이미지는 `/users/me/avatar`에서 MIME/시그니처/3MB 제한 후 파일 저장소에 두고 URL만 보관한다. 온보딩 저장 시 learner `consent_at`도 함께 세팅하며 auth E2E에서 신규 계정 온보딩→아바타 업로드→학습자 홈 이동을 검증했다. | 모델 기반 ko NER 정밀화, 실제 운영 말뭉치 기반 오탐/미탐 평가, 미성년/guardian 및 법무 검토가 필요한 최종 서명 동의서·개인정보 처리방침·약관 evidence 확보. 공개 Google OAuth 실제 `/turn` proof는 별도 운영 게이트. | doc1/2/5(실명·날짜·미성년·자살시도 다수)·doc4(IRB·개인정보) |
### Medium+ (6) — M1~M3, X1~X2, L1
@ -73,7 +73,7 @@
| **M3** | SSO claim 매핑·식별자 안정성 1차 완료·운영 IdP 감사 미연결 | Google/SAML/dev-login이 `AUTH_EMAIL_COHORT_MAP`·`AUTH_DOMAIN_COHORT_MAP` 및 SAML cohort claim을 `cohort_ids`로 전달하고, `app_user.external_id`는 provider subject(`google:`/`saml:`/`dev:`) 기반으로 저장한다. 운영 SAML 서명검증, 기관 claim schema/test tenant, deprovisioning audit은 아직 없다. | 한신 IdP 확정 후 SAML 서명검증, claim→role/cohort/institution_user_id 매핑 표 실연동, role변경/삭제 audit, deprovisioning evidence. |
| **X1** | 재귀학습·데이터셋 export 파이프라인 1차 구현 | `scripts/export-recursive-dataset.py``app.services.dataset_export`로 masked-text JSONL dry-run, PII scan, kappa/ICC 계산, approved export 게이트를 구현했다. 기본은 `technical_dry_run`이며 실제 승인 export·골든셋 승격은 데이터 steward/legal review와 IAA 통과가 필요하다. | 파일럿 evidence에서 reviewer disposition, steward/legal 승인, gold annotation 라운드 적재 후 `approved_for_recursive_learning_seed` 승격 검증. |
| **X2** | AI API 비용 관측·예산 경고·평가 저비용 라우팅·evaluator cache 관측·일별 비용 추이·모델별 비용 검증 리포트 2차 완료 | 턴별 provider/model/tokens/cost 저장 경로와 `GET /admin/usage`, 관리자 비용 대시보드를 연결했다. `ADMIN_USAGE_BUDGET_USD` 기준 예산 상태(ok/warn/exceeded)도 응답/UI에 표시한다. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL` 설정 시 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달하고, 비워두면 기존 gateway default 라우팅을 유지한다. fast/deep evaluator structured 결과는 canonical request SHA-256 기반 인메모리 semantic cache로 재사용하며, 원문 prompt·completion은 저장하지 않고 성공 파싱 결과만 TTL/entry 제한 안에서 캐시한다. `/admin/usage`와 관리자 비용 카드가 cache enabled/entries/hits/misses/stores/evictions/requests/hit_rate와 일별 `daily_cost` 추이를 노출한다. `app.services.usage_report``scripts/report-ai-usage.py`는 같은 usage JSON에서 provider/model별 cost share, token share, cost/turn, cost/1k tokens, metered coverage, budget/cache warning을 산출한다. DB 미가용 dev는 runtime store fallback, prod는 fail-closed다. | 자동 차단·한도 enforcement 정책. |
| **L1** | 기술스택 신청서-구현 불일치 및 단기일정 산출물 압박 | doc4 신청서 스택(Spring Boot 3/Node.js·TimescaleDB) vs 실제 FastAPI/Python 불일치, 20주 단기일정·9월 저작권 등재 압박. 소유자 결정으로 장기 교체 대상은 Node.js 우선, 현재 FastAPI 전면 재작성은 보류했다. 내부 전환 증거로 engine gateway 공유 계약, `EngineClient.stream_packets()` decode 경계, schema-backed golden fixture(`engine_gateway_contract.v1.json`/`engine_gateway_schema.v1.json`), Python import 없는 `scripts/check-engine-gateway-contract.mjs` Node.js conformance runner, 브라우저-facing 세션 read-model 분리(`app/session_read_model.py`), 페르소나 DTO/mapper 분리(`app/persona_read_model.py`)까지 고정했다. | 신청서/저작권 등재 문서에 FastAPI 유지 사유와 계약 우선 Node 전환 계획을 반영하는 외부 거버넌스 증거. 다음 내부 후보는 H3 항목형 저작 UI. |
| **L1** | 기술스택 신청서-구현 불일치 및 단기일정 산출물 압박 | doc4 신청서 스택(Spring Boot 3/Node.js·TimescaleDB) vs 실제 FastAPI/Python 불일치, 20주 단기일정·9월 저작권 등재 압박. 소유자 결정으로 장기 교체 대상은 Node.js 우선, 현재 FastAPI 전면 재작성은 보류했다. 내부 전환 증거로 engine gateway 공유 계약, `EngineClient.stream_packets()` decode 경계, schema-backed golden fixture(`engine_gateway_contract.v1.json`/`engine_gateway_schema.v1.json`), Python import 없는 `scripts/check-engine-gateway-contract.mjs` Node.js conformance runner, 브라우저-facing 세션 read-model 분리(`app/session_read_model.py`), 페르소나 DTO/mapper 분리(`app/persona_read_model.py`)까지 고정했다. | 신청서/저작권 등재 문서에 FastAPI 유지 사유와 계약 우선 Node 전환 계획을 반영하는 외부 거버넌스 증거. 내부 후보였던 H3 항목형 목록 저작 UI와 프롬프트 미리보기 de-JSON은 10차에서 완료. |
> X2 근거: doc3 회의록이 'AI API 비용'을 운영 리스크로 명시.
@ -85,12 +85,12 @@
### A. 즉시 착수 가능 (내부 코드, 외부 합의 불요)
- **C1 2차 완료**: `caseWorksheet` 응답 구조, 리뷰 화면 편집 UI, `app.case_worksheet` 저장/재조회 경로. 후속은 임상 루브릭·AI 추출/채점·교수자 검수.
- **C1 3차 완료**: `caseWorksheet` 응답 구조, 리뷰 화면 편집 UI, `app.case_worksheet` 저장/재조회 경로와 외부 루브릭 schema/loader/validation scaffold. 워크시트 템플릿 key source는 `CASE_WORKSHEET_SECTION_SPECS`/`case_worksheet_template_item_keys()`로 명시했다. 후속은 임상팀 확정 루브릭 콘텐츠, AI 채점 보정, 교수자 검수.
- **C2 1차 완료**: 위기 신호는 엔진 전 중단, 109 안내, `safety_events` 적재, 교수자 알림 큐, `ideation_observed` 전달까지 배선했다. 후속은 임상 스크립트·서약 문안·감점 루브릭.
- **C3 2차 완료**: `theory_mode`가 세션·평가·생성 프롬프트까지 흐르고, 학습자는 세션 시작 전 기존 3개 모드 중 하나를 명시 선택해 `POST /sessions`로 보낸다. 후속은 임상팀 CBT 체인·이론부합 루브릭.
- **H2 1차+라이브 코칭+리뷰 2열 UI 완료**: `make_eval_hook`과 stream 평가가 턴 파이프라인에 붙고 정규화 테이블로 적재·복원된다. 대안발화도 `app.alternative_utterance`로 정규화한다. 라이브 코칭은 0615 워크북·DSM·공식 지침 요약/RAG 근거로 코칭 아바타 말풍선·근거 모달·발화별 이력 오버레이까지 연결했다. `POST /kb/live-coach/source-packs/sync`는 공용 source pack sync service를 통해 evaluator 전용 RAG에 증분 색인하고, hash 변경 시 document version을 최신+1로 올린다. 학습자 `SessionReview` 데스크톱은 좌측 축어록 타임라인과 우측 작업열의 2열 구조로 재배치했고, 교수자/모바일 레이아웃은 기존 규칙을 유지한다. 후속은 골든셋·임상팀 루브릭·source pack 임상 검수 상태 운영.
- **H3 8차 완료**: 페르소나 저작 CRUD(draft→review), P4~P7 저장소 JSON 로드, RAG source 기반 draft generation, prompt bundle id/version/hash provenance와 seed/version materializer runner가 붙었다. seed runner는 dry-run/JSON manifest를 제공하고, `--apply`에서만 DB pool을 초기화해 기존 materializer를 호출한다. repo-managed source pack runner는 DB-backed dry-run/apply를 제공하며 active `content_hash`가 바뀐 문서만 최신 version+1로 sync한다. 이번 패스에서 `kb.raw_source_artifact` hash-only 레코드와 `rag.index_document()` raw/sensitivity=3 fail-closed guard를 추가해 raw 원문이 `kb.chunk`/embedding/FTS로 들어가지 않게 했고, `app/persona_read_model.py`로 persona DTO/mapper 경계를 분리했다. 후속은 항목형 저작 UI 고도화, 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수 evidence, 암호화 blob/vault 기반 원문 실저장.
- **H4(부분)·X1·X2**: 한국어 날짜/금액/주소 및 이름/기관 로컬 휴리스틱 마스킹, 합성 fixture 평가 harness, 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료했다. 모델 기반 정밀 ko NER, 실제 운영 말뭉치 기반 평가, guardian/legal 서명 evidence는 후속이다. dry-run JSONL export·PII scan·IAA 계산 1차도 완료했고 `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고, evaluator fast/deep 모델 override, evaluator semantic cache, 운영 hit-rate 관측, 일별 비용 추이, 모델별 비용 검증 리포트는 완료했고 자동 차단·한도 enforcement 정책은 후속. M1은 provider_events 보존 슬롯, 내부 taxonomy, 인증 리뷰용 제한 파생 칩까지 완료했고, M2는 보수적 identity/agreement pinned_fact 자동 실적재, append-only history, 명시적 상담 약속 철회 contradiction, episodic embedding writer까지 완료했다. L1은 Node.js conformance runner와 session/persona read-model 분리까지 완료했다. 실제 provider 기반 한숨·울음 감지는 후속.
- **H3 10차 완료**: 페르소나 저작 CRUD(draft→review), P4~P7 저장소 JSON 로드, RAG source 기반 draft generation, prompt bundle id/version/hash provenance와 seed/version materializer runner가 붙었다. seed runner는 dry-run/JSON manifest를 제공하고, `--apply`에서만 DB pool을 초기화해 기존 materializer를 호출한다. repo-managed source pack runner는 DB-backed dry-run/apply를 제공하며 active `content_hash`가 바뀐 문서만 최신 version+1로 sync한다. `kb.raw_source_artifact` hash-only 레코드와 `rag.index_document()` raw/sensitivity=3 fail-closed guard를 추가해 raw 원문이 `kb.chunk`/embedding/FTS로 들어가지 않게 했고, `app/persona_read_model.py`로 persona DTO/mapper 경계를 분리했다. PersonaStudio의 자동사고·회기 시나리오·말투 목록·역린/금기 목록은 행 추가/삭제 UI로 바꾸고 기존 배열 payload 계약을 유지한다. 이번 패스에서 프롬프트 미리보기 raw JSON textarea를 라벨형 검토 섹션으로 교체했다. 후속은 루브릭·이론 콘텐츠 외부화, 임상팀 최종 검수 evidence, 암호화 blob/vault 기반 원문 실저장.
- **H4(부분)·X1·X2**: 한국어 날짜/금액/주소 및 이름/기관 로컬 휴리스틱 마스킹, 15-case 합성 fixture 평가 harness와 input/report schema(summary-only `technical_dry_run` report), 자연 발화형 이름 라벨·자기소개 마스킹 보강, 외부 LLM 호출 metadata-only `audit.llm_call_log` 적재 경로, 학습자 동의 수락/철회/회기 시작 하드게이트 골격은 완료했다. 모델 기반 정밀 ko NER, 실제 운영 말뭉치 기반 평가, guardian/legal 서명 evidence는 후속이다. dry-run JSONL export·PII scan·IAA 계산 1차도 완료했고 `ds.*` write는 `--write-dataset` 명시 시에만 수행한다. X2 비용 관측·예산 경고, evaluator fast/deep 모델 override, evaluator semantic cache, 운영 hit-rate 관측, 일별 비용 추이, 모델별 비용 검증 리포트는 완료했고 자동 차단·한도 enforcement 정책은 후속. M1은 provider_events 보존 슬롯, 내부 taxonomy, 인증 리뷰용 제한 파생 칩까지 완료했고, M2는 보수적 identity/agreement pinned_fact 자동 실적재, append-only history, 명시적 상담 약속 철회 contradiction, episodic embedding writer까지 완료했다. L1은 Node.js conformance runner와 session/persona read-model 분리까지 완료했다. 실제 provider 기반 한숨·울음 감지는 후속.
### B. 소유자 결정 / 외부(임상팀·기관) 의존

View file

@ -6,7 +6,7 @@
분류: **B1 비차단 폴리시** · **B2 환경 제약(증거 생산 불가)** · **B3 소유자 결정** · **B4 외부 거버넌스**
> **B0. 원천문서 갭 분석 (2026-06-26 추가)** — 한신대 산학협력 원천문서 5종 정독으로 도출한 "부족한 부분"(critical 3 / high 4 / medium+ 6)은 **SSOT 대시보드** `docs/dev_dashboard.html` "원천문서 갭 분석" 섹션과 상세 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1 사례개념화 산출물은 저장형 워크시트까지 2차 구조를 만들었고, C2 위기개입 프로토콜은 1차 구조, C3 이론모드는 2차 명시 선택 UI까지 만들었다. 콘텐츠 정의는 임상팀(구훈정·어유경) 소유라 코드는 구조를 선제 구축하되 임상 문안과 평가기준은 외부 정의로 받는다.
> **B0. 원천문서 갭 분석 (2026-06-26 추가)** — 한신대 산학협력 원천문서 5종 정독으로 도출한 "부족한 부분"(critical 3 / high 4 / medium+ 6)은 **SSOT 대시보드** `docs/dev_dashboard.html` "원천문서 갭 분석" 섹션과 상세 `docs/ops/source-docs-gap-analysis-2026-06-26.md`에서 추적한다. C1 사례개념화 산출물은 저장형 워크시트와 외부 루브릭 scaffold까지 3차 구조를 만들었고, C2 위기개입 프로토콜은 1차 구조, C3 이론모드는 2차 명시 선택 UI까지 만들었다. 콘텐츠 정의는 임상팀(구훈정·어유경) 소유라 코드는 구조를 선제 구축하되 임상 문안과 평가기준은 외부 정의로 받는다.
---
@ -57,7 +57,7 @@
- [ ] **재귀학습 fine-tuning 범위** — few-shot 자동갱신만 / fine-tuning 포함. 영향: 동의서·데이터셋 품질·운영 리스크.
- [ ] **자유연습 기본값** — 기본 ON(+피드백 권장 배지+교수자 토글) / 교수자 승인 후 ON. 영향: 학습자 자율성·평가 품질·안전 정책.
- [ ] **추가 축어록 수급 규모·일정** — 목표 N건/일정 확정. 영향: 평가 타당도·페르소나 다양성·Phase 3 측정력.
- [x] **백엔드 언어 방향** — (2026-06-28 결정) Node.js를 장기 교체 대상과 신규 분리 서비스 우선 스택으로 삼되, 현재 FastAPI 전면 재작성은 납품 일정 리스크라 보류한다. 전환은 계약 우선 strangler 방식으로 진행한다. 1차 경계로 `app/contracts/engine_gateway.py`를 추가해 FastAPI client와 현재 Python gateway가 `/v1/generate`, `/v1/stream`, SSE `token/done/error` 계약을 공유한다. 이어서 raw gateway SSE line 해석을 `EngineClient.stream_packets()`로 올려 API orchestrator가 `EngineGatewaySsePacket`만 처리하게 했고, Python gateway `/v1/generate` 응답은 `GenerateResponse.model_dump()`로 고정해 hand-mirrored dict drift를 줄였다. 2차로 `apps/api/engine_gateway/golden/engine_gateway_contract.v1.json``engine_gateway_schema.v1.json`을 추가하고, 3차로 `scripts/check-engine-gateway-contract.mjs` Node.js conformance runner를 붙여 Node gateway가 Python import 없이 request/response/SSE packet conformance를 검증할 수 있게 했다. 4차로 `app/session_read_model.py`를 추가해 세션 목록·대시보드·상세·리뷰·공유 payload의 브라우저-facing DTO와 deterministic builder를 `routes/sessions.py`에서 분리했다. 5차로 `app/persona_read_model.py`를 추가해 페르소나 catalog/review/draft/source/evidence DTO와 deterministic mapper를 `routes/personas.py`에서 분리했다. 각 route는 auth/RLS DB read/persistence, teacher/admin gate, RAG/LLM side effect, session lifecycle을 계속 소유한다. 결정 기록: `docs/decisions/backend-node-transition.md`. **검증: `node scripts/check-engine-gateway-contract.mjs --json`, `python -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py -q` 19 passed, `python -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` 50 passed, `python -B -m py_compile app/session_read_model.py app/routes/sessions.py`, session read-model focused 48 passed, `python -B -m py_compile app/persona_read_model.py app/routes/personas.py`, persona read-model focused 33 passed, `npm run check:api-types`, `npm run typecheck`.**
- [x] **백엔드 언어 방향** — (2026-06-28 결정) Node.js를 장기 교체 대상과 신규 분리 서비스 우선 스택으로 삼되, 현재 FastAPI 전면 재작성은 납품 일정 리스크라 보류한다. 전환은 계약 우선 strangler 방식으로 진행한다. 1차 경계로 `app/contracts/engine_gateway.py`를 추가해 FastAPI client와 현재 Python gateway가 `/v1/generate`, `/v1/stream`, SSE `token/done/error` 계약을 공유한다. 이어서 raw gateway SSE line 해석을 `EngineClient.stream_packets()`로 올려 API orchestrator가 `EngineGatewaySsePacket`만 처리하게 했고, Python gateway `/v1/generate` 응답은 `GenerateResponse.model_dump()`로 고정해 hand-mirrored dict drift를 줄였다. 2차로 `apps/api/engine_gateway/golden/engine_gateway_contract.v1.json``engine_gateway_schema.v1.json`을 추가하고, 3차로 `scripts/check-engine-gateway-contract.mjs` Node.js conformance runner를 붙여 Node gateway가 Python import 없이 request/response/SSE packet conformance를 검증할 수 있게 했다. 4차로 `app/session_read_model.py`를 추가해 세션 목록·대시보드·상세·리뷰·공유 payload의 브라우저-facing DTO와 deterministic builder를 `routes/sessions.py`에서 분리했다. 5차로 `app/persona_read_model.py`를 추가해 페르소나 catalog/review/draft/source/evidence DTO와 deterministic mapper를 `routes/personas.py`에서 분리했다. 각 route는 auth/RLS DB read/persistence, teacher/admin gate, RAG/LLM side effect, session lifecycle을 계속 소유한다. 결정 기록: `docs/decisions/backend-node-transition.md`. **검증: `node scripts/check-engine-gateway-contract.mjs --json`, `python -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py -q` 19 passed, `python -B -m pytest -p no:cacheprovider engine_gateway/test_gateway_model.py app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` 50 passed, `python -B -m py_compile app/session_read_model.py app/routes/sessions.py`, session read-model focused 48 passed, `python -B -m py_compile app/persona_read_model.py app/routes/personas.py`, persona read-model focused 33 passed, persona/session-start 55 passed, `npm run check:api-types`, `npm run typecheck`.**
---
@ -87,14 +87,14 @@
- 교수자 성장 추적/리뷰 상세 진입 1차: `teacher_dashboard`가 정규화된 턴 평가를 기반으로 학습자별 적절성·라포·기술 사용 추이를 집계하고, 교수 콘솔에 최근 회기/항목별 성장 카드와 안전 알림 큐를 함께 표시한다. 종료 회기 리뷰 대기 행과 최근 회기 행은 `/teach/session/:sessionId/review`로 이어지고, 교수자는 회기 리뷰와 사례개념화 워크시트를 읽기 전용으로 검토한다. 학습자 워크시트 저장은 계속 learner 전용이다. **검증: `pytest app/test_teacher_dashboard.py app/test_session_turn_persistence.py app/test_voice_ws.py -q` 21 passed, `pytest app/test_rbac_idor.py app/test_teacher_dashboard.py -q` 9 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1` 4 passed.**
- 교수자 검토 상태 1차: `app.session_review_status`에 교수자 회기 검토 상태를 저장하고, teacher dashboard 응답에 `review_status/review_note/reviewed_at`를 포함한다. `PUT /teacher/sessions/{session_id}/review-status`로 교수자 메모 저장과 검토 완료 처리를 수행하며, 완료된 회기는 pending queue에서 제외한다. 교수자/관리자의 `/sessions/{id}/review` 읽기 허용은 유지하고, 학습자 워크시트 저장은 learner 전용으로 계속 제한한다. 교수 콘솔 검토 큐는 숨김 처리하지 않고 상단 triage로 노출하며, 학습자 리뷰 화면에는 교수자 전용 grid 빈칸을 예약하지 않는다. **검증: `pytest app/test_teacher_dashboard.py app/test_rbac_idor.py app/test_learner_dashboard.py -q` 12 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `teacher.spec.ts` 4 passed, `session-review.spec.ts` 2 passed, `layout-visual-gate.spec.ts` 7 passed.**
- 학습자 개인화 대시보드 1차: 교수자 성장 집계와 중복되던 점수/라포/기법 계산을 `app.services.session_metrics`로 공용화하고, `GET /sessions/dashboard`가 본인 세션 기반 `overview/growth/persona_progress/achievements/recent_feedback`를 반환한다. 학습자 홈은 누적 회기, 리뷰 대기, 최근 평가, 라포 흐름, 페르소나별 진행, 최근 피드백, 마지막 세션 리캡을 실제 서버 데이터로 표시한다. 성취는 공식 등급/수료가 아니라 실제 연습 milestone만 사용한다. **검증: `pytest app/test_learner_dashboard.py app/test_teacher_dashboard.py app/test_rbac_idor.py -q` 12 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `learner.spec.ts` 6 passed, `session-review.spec.ts` 2 passed, `teacher.spec.ts` 4 passed, `layout-visual-gate.spec.ts` 7 passed, `session-layout.spec.ts` 4 passed.**
- 페르소나 스튜디오/RAG 저작 8차: 교수 콘솔의 JSON 패널을 분리해 teacher/admin 전용 `/teach/personas` 3열 스튜디오(좌: 저작 흐름·카탈로그·검수 큐, 중앙: RAG 자료 등록·항목형 편집 탭, 우: 검증·RAG 근거·검수 결정)로 옮겼다. 2026-06-27 UI 정리에서 중복 네이티브 파일 입력을 커스텀 SSOT 첨부 드롭존으로 교체하고, 저작 스테퍼·탭별 작성 가이드·검증 dot 정렬을 보강했다. 레이아웃 근거와 생성 시안은 `docs/ops/layout-research-2026-06-27/persona-dashboard-layout-guideline.md`에 묶었다. `POST /personas/sources`는 첨부/붙여넣기 자료를 PII 마스킹 후 raw 원문 hash-only 증거를 `kb.raw_source_artifact`에 따로 기록하고, sanitized 파생본만 `kb.source/document/chunk`에 evaluator 전용(`visible_to=['evaluator']`, `sensitivity=2`) 근거 문서로 등록한다. `rag.index_document()``sensitivity=3` 또는 raw marker chunk를 DB 접근 전에 차단해 raw 원문이 `kb.chunk`/embedding/FTS에 들어가지 않게 한다. `POST /personas/drafts/generate``source_id` 기반 RAG 검색 결과만 생성 프롬프트에 넘긴다. 생성 응답과 draft `source_provenance`에는 source id, doc/content hash, chunk id, prompt bundle id/version/hash(`persona-draft-rag@2026-06-28.1`)를 남긴다. P1~P7 시스템 페르소나는 DB 저작 카탈로그의 초기 부트스트랩으로 승격했고, `materialize_seed_personas()`는 누락분만 insert해서 교수 편집본이나 `archived` 보관본을 덮어쓰지 않는다. `scripts/materialize-persona-seeds.py`는 같은 seed/version manifest를 dry-run 기본으로 보고하며, `--apply`일 때만 DB pool을 초기화한 뒤 기존 idempotent materializer를 호출한다. `scripts/sync-persona-sources.py``app.services.source_pack_sync`는 repo-managed source pack의 active `content_hash`를 DB에서 비교하고, 변경 시 `kb.document.version`을 최신+1로 색인한다. 교수자는 공개 목록에서 승인본을 다음 버전 draft로 복제해 수정하고, 불필요한 페르소나는 기존 회기 FK 보존을 위해 같은 code family 전체를 `archived`로 보관 처리한다. `app/persona_read_model.py`는 catalog/review/draft/source/evidence DTO와 mapper를 route에서 분리해 schema 이름과 `source/degraded` fail-closed 경계를 유지한다. 암호화 blob/vault 기반 원문 실저장, 임상팀 최종 검수/서면 evidence, 루브릭·이론 콘텐츠 외부화는 후속. **검증: `python -B -m py_compile app/services/rag.py app/routes/personas.py app/routes/kb.py app/test_persona_review.py app/test_live_coach_sources.py`, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_live_coach_sources.py -q` 38 passed, `python -B -m py_compile app/persona_read_model.py app/routes/personas.py`, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py -q` 33 passed, `npm run check:api-types`, `npm run typecheck`, `py -3.11 scripts\materialize-persona-seeds.py --json` dry-run `manifest_count=7`, `py -3.11 scripts\sync-persona-sources.py --help`, 기존 `npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1` 6 passed, `layout-visual-gate.spec.ts` 8 passed, `session-layout.spec.ts` desktop/mobile 8 passed.**
- 페르소나 스튜디오/RAG 저작 10차: 교수 콘솔의 JSON 패널을 분리해 teacher/admin 전용 `/teach/personas` 3열 스튜디오(좌: 저작 흐름·카탈로그·검수 큐, 중앙: RAG 자료 등록·항목형 편집 탭, 우: 검증·RAG 근거·검수 결정)로 옮겼다. 2026-06-27 UI 정리에서 중복 네이티브 파일 입력을 커스텀 SSOT 첨부 드롭존으로 교체하고, 저작 스테퍼·탭별 작성 가이드·검증 dot 정렬을 보강했다. 2026-06-28 항목형 목록 UI 정리에서 자동사고, 회기 시나리오, 말투 filler/verbal tic/nonverbal cue, 역린·금기 응답·금기어를 행 추가/삭제 UI로 전환했고, 저장 payload는 기존 배열 schema를 유지하며 빈 항목만 저장 직전에 제거한다. 프롬프트 탭은 raw JSON textarea 대신 L1 카드·인적 범주·임상 배경·말투·수치 파라미터·역린·회기 시나리오·추가 계약 섹션으로 같은 draft 데이터를 라벨형 검토 UI에 표시한다. 레이아웃 근거와 생성 시안은 `docs/ops/layout-research-2026-06-27/persona-dashboard-layout-guideline.md`에 묶었다. `POST /personas/sources`는 첨부/붙여넣기 자료를 PII 마스킹 후 raw 원문 hash-only 증거를 `kb.raw_source_artifact`에 따로 기록하고, sanitized 파생본만 `kb.source/document/chunk`에 evaluator 전용(`visible_to=['evaluator']`, `sensitivity=2`) 근거 문서로 등록한다. `rag.index_document()``sensitivity=3` 또는 raw marker chunk를 DB 접근 전에 차단해 raw 원문이 `kb.chunk`/embedding/FTS에 들어가지 않게 한다. `POST /personas/drafts/generate``source_id` 기반 RAG 검색 결과만 생성 프롬프트에 넘긴다. 생성 응답과 draft `source_provenance`에는 source id, doc/content hash, chunk id, prompt bundle id/version/hash(`persona-draft-rag@2026-06-28.1`)를 남긴다. P1~P7 시스템 페르소나는 DB 저작 카탈로그의 초기 부트스트랩으로 승격했고, `materialize_seed_personas()`는 누락분만 insert해서 교수 편집본이나 `archived` 보관본을 덮어쓰지 않는다. `scripts/materialize-persona-seeds.py`는 같은 seed/version manifest를 dry-run 기본으로 보고하며, `--apply`일 때만 DB pool을 초기화한 뒤 기존 idempotent materializer를 호출한다. `scripts/sync-persona-sources.py``app.services.source_pack_sync`는 repo-managed source pack의 active `content_hash`를 DB에서 비교하고, 변경 시 `kb.document.version`을 최신+1로 색인한다. 교수자는 공개 목록에서 승인본을 다음 버전 draft로 복제해 수정하고, 불필요한 페르소나는 기존 회기 FK 보존을 위해 같은 code family 전체를 `archived`로 보관 처리한다. `app/persona_read_model.py`는 catalog/review/draft/source/evidence DTO와 mapper를 route에서 분리해 schema 이름과 `source/degraded` fail-closed 경계를 유지한다. 암호화 blob/vault 기반 원문 실저장, 임상팀 최종 검수/서면 evidence, 루브릭·이론 콘텐츠 외부화는 후속. **검증: `python -B -m py_compile app/services/rag.py app/routes/personas.py app/routes/kb.py app/test_persona_review.py app/test_live_coach_sources.py`, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_live_coach_sources.py -q` 38 passed, `python -B -m py_compile app/persona_read_model.py app/routes/personas.py`, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py -q` 33 passed, `python -B -m pytest -p no:cacheprovider app/test_persona_review.py app/test_session_turn_persistence.py -q` 55 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `py -3.11 scripts\materialize-persona-seeds.py --json` dry-run `manifest_count=7`, `py -3.11 scripts\sync-persona-sources.py --help`, `npx playwright test e2e/teacher.spec.ts --project=chromium-single-run --workers=1` 8 passed, 프롬프트 탭 포함 `layout-visual-gate.spec.ts` 9 passed, `session-layout.spec.ts` desktop/mobile 8 passed.**
- M1 비언어 이벤트 4차: voice learner turn에 이미 저장하던 `audio_ref`/`silence_ms`/`speech_rate`/`barge_in`을 리뷰 API `nonverbal` 이벤트로 노출하고, 회기 리뷰 축어록에 침묵·발화 속도·끼어듦·음성 입력 칩을 표시했다. 2차에서는 `app.turns.provider_events JSONB``TurnRecord.provider_events`를 추가해 WebSocket control/STT provider 이벤트를 allowlist·size limit 후 보존했고, 3차에서는 저장 전 sanitizer에서 내부 taxonomy `event_type`/`category`를 붙인다. 4차에서는 인증된 회기 리뷰에 한숨·울음·웃음·호흡·운율·배경소음 계열만 한글 label/detail 칩으로 파생 노출한다. raw transcript/text/provider/source/raw type은 응답에서 제외하고, 공개 공유 카드에는 축어록과 provider raw를 싣지 않는다. 실제 provider 기반 한숨·울음 감지, 역량 지표화 정책, live 마이크/WSS 장시간 실측은 후속. **검증: `python -B -m py_compile app/routes/sessions.py app/test_session_turn_persistence.py`, `python -B -m pytest -p no:cacheprovider app/test_voice_ws.py app/test_session_turn_persistence.py app/test_voice_service.py -q` 45 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/session-review.spec.ts --project=chromium-desktop --workers=1` 3 passed.**
- M2 다회기 케이스 아크 6차: DB 세션 생성 시 `(persona_id, learner_id)` 기준 `case_profile`을 upsert하고, `session_no`를 트랜잭션 안에서 원자 증가시키며, `InProcSession.case_id`가 매회 새 `runtime_case_id`가 아니라 안정 `case_id`를 가리키게 수정했다. 시작/submit/stream/voice 경로는 case recall cache를 사용한다. 세션 종료 시 마스킹 축어록 기반 fallback `session_summary.digest``case_profile.case_digest`, `rapport_trajectory`, `alliance_level`을 갱신하고, 다음 회기 seed recall에서 `case_digest`·직전 `session_summary`·client-visible non-contradicted `pinned_fact`를 함께 조립한다. 3차에서는 마스킹된 client-visible 발화에서 `[NAME]`/`[ORG]` identity와 명시적 상담 약속만 보수적으로 `pinned_fact`에 upsert했고, 4차에서는 삽입 또는 값 변경 시 `pinned_fact_history`에 append-only 이력을 남긴다. 5차에서는 명시적 상담 약속 철회/부정만 기존 non-locked `agreement:counseling` fact를 `contradicted`로 격리하고 history reason `contradiction`을 남긴다. 6차에서는 세션 종료 저장 성공 뒤 마스킹된 client-visible 내담자 발화만 `app.turn_embedding`에 BGE-M3 dense/sparse로 `ON CONFLICT (turn_id) DO NOTHING` 색인한다. 새 contradicted fact는 임의 생성하지 않고, 같은 값 재확인은 history를 늘리지 않으며, `locked` fact는 건드리지 않는다. learner-owned `case_profile` 기준 RLS insert/update를 추가했고, 관계갈등·위기·임상 추론은 자동 pinning/모순 처리에서 제외했다. 관계·임상 fact 승격 기준과 LLM digest 압축은 후속. **검증: `python -B -m py_compile app/services/rag.py app/routes/sessions.py app/test_session_memory.py`, `python -B -m pytest -p no:cacheprovider app/test_session_memory.py -q` 14 passed, `python -B -m pytest -p no:cacheprovider app/test_session_memory.py app/test_session_turn_persistence.py app/test_orchestrator_masking.py app/test_runtime_policy.py -q` 67 passed.**
- C1 사례개념화 워크시트 2차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 카드가 탐색 11항목·호소 5영역·인지삼제/감정·보호/방해·생물심리사회 목표 초안을 축어록 근거 기반으로 제공한다. 학습자가 편집한 저장본은 `PUT /sessions/{id}/review/worksheet``app.case_worksheet`에 저장되고, 이후 `GET /review``saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. 임상 루브릭, AI 추출/채점, 교수자 검수는 후속. **검증: `pytest app/test_session_turn_persistence.py app/test_rbac_idor.py -q` 25 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, 로컬 API smoke 세션 생성→저장→`GET /review` `saved_by_learner:local smoke saved worksheet`.**
- C1 사례개념화 워크시트 3차: `SessionReviewResponse.caseWorksheet`와 리뷰 화면 카드가 탐색 11항목·호소 5영역·인지삼제/감정·보호/방해·생물심리사회 목표 초안을 축어록 근거 기반으로 제공한다. 학습자가 편집한 저장본은 `PUT /sessions/{id}/review/worksheet``app.case_worksheet`에 저장되고, 이후 `GET /review``saved_by_learner` 저장본을 자동 초안보다 우선 반환한다. 2026-06-28에는 `data/rubrics/case-worksheet-rubric.json`, schema, loader/validator service, `scripts/check-case-worksheet-rubric.py`를 추가해 임상팀 확정 루브릭을 외부 JSON으로 받을 scaffold를 만들었다. 현재는 `status=scaffold_only`, `scoring_enabled=false`이며 5개 section/28개 item key와 sha256 provenance만 검증한다. 64차 refactor-governance 패스에서 워크시트 템플릿 key source를 `CASE_WORKSHEET_SECTION_SPECS`/`case_worksheet_template_item_keys()`로 명시해 CLI/test가 더미 턴 없이 같은 생성 spec을 검증하게 했다. 임상팀 확정 루브릭 콘텐츠, AI 추출/채점 적용·calibration, 교수자 승인/반려/수정요청 검수는 후속 gate다. **검증: `pytest app/test_session_turn_persistence.py app/test_rbac_idor.py -q` 25 passed, `py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_case_worksheet_rubric.py -q` 4 passed, `py -3.11 -X utf8 scripts/check-case-worksheet-rubric.py --json` PASS, `py -3.11 -m py_compile apps/api/app/services/case_worksheet_rubric.py apps/api/app/test_case_worksheet_rubric.py scripts/check-case-worksheet-rubric.py`, `npm run check:api-types`, `npm run typecheck`, `npm run build`, 로컬 API smoke 세션 생성→저장→`GET /review` `saved_by_learner:local smoke saved worksheet`.**
- C3 이론모드 2차: `theory_mode``TurnContext`/sessions/voice/evaluator뿐 아니라 `build_turn_messages`의 엔진 메시지까지 전달된다. 프론트는 `persona.theory_target` 기준 기본값을 유지하되, 세션 시작 전 `humanistic`/`cbt`/`integrative` segmented control로 학습자가 명시 선택하고 `POST /sessions``theory_mode`로 보낸다. CBT 체인·이론부합 루브릭은 후속. **검증: `python -B -m pytest -p no:cacheprovider app/test_orchestrator_masking.py app/test_session_turn_persistence.py -q` 31 passed, `npm run typecheck`, `npm run build`, `npx playwright test e2e/session-layout.spec.ts --project=chromium-desktop --workers=1` 4 passed, `npx playwright test e2e/session-layout.spec.ts --project=chromium-mobile --workers=1` 4 passed, `npx playwright test e2e/session-mvp.spec.ts --project=chromium-single-run --workers=1` 1 passed, `npx playwright test e2e/layout-visual-gate.spec.ts --project=chromium-single-run --workers=1` 9 passed.**
- M3 인증 claim 1차: Google/SAML/dev-login이 `AUTH_EMAIL_COHORT_MAP`·`AUTH_DOMAIN_COHORT_MAP` 및 SAML cohort claim을 `cohort_ids`로 전달하고, DB `app_user.external_id`는 provider subject(`google:`/`saml:`/`dev:`) 기반으로 저장한다. 운영 SAML 서명검증, 기관 claim schema/test tenant, deprovisioning audit은 후속. **검증: `pytest app/test_auth_providers.py -q` 30 passed, `pytest app/ -q` 178 passed.**
- X2 예산 경고/저비용 평가 라우팅/evaluator cache 관측·일별 비용 추이·모델별 비용 검증 리포트 2차: `ADMIN_USAGE_BUDGET_USD` 설정값을 기준으로 `GET /admin/usage``budget.status=disabled|ok|warn|exceeded`, 사용률, 잔여 예산을 반환하고 관리자 콘솔이 예산 상태 배너를 표시한다. 80% 이상 warn, 100% 이상 exceeded. `EVALUATOR_FAST_MODEL`/`EVALUATOR_DEEP_MODEL` 설정 시 fast/deep 평가 호출만 해당 모델 override로 gateway에 전달하고, 비워두면 기존 gateway default 라우팅을 유지한다. fast/deep evaluator structured 결과는 canonical request SHA-256 기반 인메모리 semantic cache로 재사용하며, 원문 prompt·completion은 저장하지 않고 성공 파싱 결과만 TTL/entry 제한 안에서 캐시한다. `/admin/usage`와 관리자 `AI 비용` 카드는 cache enabled/entries/hits/misses/stores/evictions/requests/hit_rate와 일별 `daily_cost` 추이를 표시하되 cache key·prompt·completion은 노출하지 않는다. `app.services.usage_report``scripts/report-ai-usage.py`는 동일 usage JSON에서 provider/model별 cost share, token share, cost/turn, cost/1k tokens, metered coverage, budget/cache warning을 산출한다. 자동 차단·한도 enforcement 정책은 후속. **검증: `python -B -m py_compile app/services/usage_report.py app/test_usage_report.py ..\..\scripts\report-ai-usage.py`, `python -B -m pytest -p no:cacheprovider app/test_usage_report.py app/test_evaluator_model_routing.py app/test_runtime_policy.py app/test_admin_ops.py -q` 42 passed, `scripts/report-ai-usage.py` sample CLI schema `vignette.ai_usage_model_cost_report.v1` 생성, prior `npm run check:api-types`, `npm run typecheck`, `npm run build`, admin E2E evidence remains valid because API response shape was not changed.**
- H4 LLM call audit/마스킹 2차: 상담 생성(generate/stream)·fast-loop 평가·deep-loop 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않고 감사 실패는 상담 루프를 막지 않는다. 2026-06-28에는 한국어 이름/기관 로컬 휴리스틱 마스킹 1차를 추가했고, fast evaluator prompt의 내담자 응답과 client turn `text_masked`도 마스킹본을 쓰게 보강했다. 이어서 `app.services.pii_masking_eval`, `data/privacy/pii-masking-ko-fixtures.json`, `scripts/evaluate-pii-masking.py`로 합성 NAME/ORG 5케이스 평가 harness를 추가했고, `소속`/`안내` NAME 오탐을 stopword로 보정했다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다(session `2460d56c-a9cb-4a40-a175-9575d510a5e9`). 공개 Google OAuth 실제 `/turn` proof는 별도 B2 항목에 남긴다. **검증: `python -B -m py_compile app/services/guardrail.py app/services/pii_masking_eval.py app/test_pii_masking_eval.py scripts/evaluate-pii-masking.py`, `python -B -m pytest -p no:cacheprovider app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py -q` 41 passed, `python -X utf8 scripts/evaluate-pii-masking.py --json` 5/5 pass, 과거 전체 기준 `pytest app/ -q` 119 passed.**
- H4 LLM call audit/마스킹 2차: 상담 생성(generate/stream)·fast-loop 평가·deep-loop 평가의 외부 LLM 호출 직후 `audit.llm_call_log`에 provider/model/token/cost/inference_geo/latency metadata만 적재한다. prompt/completion 본문은 저장하지 않고 감사 실패는 상담 루프를 막지 않는다. 2026-06-28에는 한국어 이름/기관 로컬 휴리스틱 마스킹 1차를 추가했고, fast evaluator prompt의 내담자 응답과 client turn `text_masked`도 마스킹본을 쓰게 보강했다. 이어서 `app.services.pii_masking_eval`, `data/privacy/pii-masking-ko-fixtures.json`, `scripts/evaluate-pii-masking.py`로 합성 fixture 15케이스 평가 harness를 추가했고, `data/privacy/pii-masking-eval-input.schema.json``data/privacy/pii-masking-eval-report.schema.json`으로 source/category/severity metadata와 summary-only `technical_dry_run` report 계약을 고정했다. 66차에서는 `제 이름은 김서연입니다`, `보호자 이름은 박민수입니다`, `저는 최하늘입니다` 자연 발화형 이름 라벨·자기소개 케이스와 `이름은 중요하지 않다` negative control을 추가해 라벨 단어만 마스킹하고 실명을 남기던 구멍을 막았다. 기본 CLI JSON은 `masked_text`/`forbidden_remaining` 원문 증거를 제외하며, `소속`/`안내`/`이름` NAME 오탐도 stopword로 보정했다. 로컬 dev-login 실제 `/turn` smoke에서 `audit.llm_call_log`가 8→11로 3행 증가했다(session `2460d56c-a9cb-4a40-a175-9575d510a5e9`). 공개 Google OAuth 실제 `/turn` proof는 별도 B2 항목에 남긴다. **검증: `py -3.11 -X utf8 -m py_compile apps/api/app/services/guardrail.py apps/api/app/services/pii_masking_eval.py apps/api/app/test_pii_masking_eval.py scripts/evaluate-pii-masking.py`, `py -3.11 -X utf8 -m pytest -p no:cacheprovider app/test_pii_masking_eval.py app/test_orchestrator_masking.py app/test_evaluation_persistence.py app/test_session_turn_persistence.py -q` 43 passed, `py -3.11 -X utf8 scripts/evaluate-pii-masking.py --json` 15/15 pass, 과거 전체 기준 `pytest app/ -q` 119 passed.**
- H4 온보딩·동의 게이트 3차: `app_user`에 이름·소속·학과·학년/직위·전화번호·주소/수령지·닉네임·자기소개·아바타 URL·약관/개인정보 동의 시각·버전 필드를 추가했다. 로그인 직후 `/onboarding` 완료 전에는 전역 `OnboardingGate``/onboarding` 외 모든 앱 URL(`/learn`, `/settings`, `/admin`, `/dev/avatar-preview`, `/login` 포함)을 온보딩으로 이동시키고, 온보딩 중에는 공용 셸 메뉴 없이 가입 정보 입력 폼만 보여준다. learner `POST /sessions`와 voice dev persona 시작은 `onboarding_required`를 먼저 확인하고, 온보딩 저장 시 learner `consent_at`도 함께 세팅한다. 아바타는 `/users/me/avatar`에서 PNG/JPG/WebP MIME·시그니처·3MB 제한을 통과한 파일만 `USER_UPLOAD_DIR/profile-avatars`에 저장하고 DB에는 URL만 보관한다. 약관·개인정보 처리방침은 개인정보 보호법·처리방침 작성지침·안전성 확보조치 기준·약관규제법 취지를 반영한 개발 초안으로 `/users/legal-docs`에서 제공한다. 관리자/교수자 권한은 온보딩 화면에서 신청받지 않고 기존 `AUTH_ADMIN_EMAILS`/`AUTH_TEACHER_EMAILS` allowlist 및 관리자 사용자 관리 경로로만 부여한다. 한국어 이름/기관은 로컬 휴리스틱 마스킹과 합성 fixture 평가 harness 1차까지 완료했고, 모델 기반 정밀 ko NER, 실제 운영 말뭉치 기반 오탐/미탐 평가, guardian/legal 최종 서명 동의서 evidence, 공개 Google OAuth 실제 `/turn` proof는 후속. **검증: `pytest app/ -q` 178 passed, `pytest engine_gateway/ -q` 11 passed, `npm run check:api-types`, `npm run typecheck`, `npm run build`, `npx playwright test e2e/auth.spec.ts --project=chromium-desktop --workers=1` 7 passed, `PLAYWRIGHT_PORT=5174 npm run e2e` 113 passed.**
- RAG warm 동시성 안정화: layout/session E2E가 여러 세션을 빠르게 만들 때 BGE-M3 embedder가 동시에 여러 번 지연 로드되며 `tqdm` lock 예외와 API health/dev-login timeout이 반복됐다. `rag.py`에서 embedder load/encode를 process-wide `RLock`으로 직렬화하고, `sessions.py``_warm_rag_caches`를 semaphore 1개로 제한해 warm task가 상담 요청 경로를 막지 않도록 했다. **검증: `pytest app/ -q` 125 passed, `npx playwright test e2e/layout-visual-gate.spec.ts e2e/session-layout.spec.ts --project=chromium-single-run --project=chromium-desktop --workers=1` 11 passed.**
- 공개/로컬/Tailnet 로그인 복구: public API 530 원인은 prod에서 개발 전용 `VIGNETTE_VOICE_POC_SAMPLE_TTS=true`가 fail-close된 것과 DB `app.admin_engine_config` 기본 행 부재였다. 2026-06-27 모바일 502는 cloudflared 로그의 `127.0.0.1:8001` origin refused와 일치했다. `scripts/start-public-runtime.ps1`는 public prod 기동 시 샘플 TTS를 강제로 끄고, 운영 DB에는 `claude_cli`/`127.0.0.1:9099` engine config 행을 복구했다. 추가로 dev/Tailnet에서는 public OAuth callback이 로컬/Tailnet 세션으로 붙지 않으므로 Google 버튼과 직접 시작 URL을 `local_oauth_unavailable`로 차단하고 dev-login만 사용한다. public OAuth state는 HttpOnly 쿠키에 묶인 HMAC 서명 토큰으로 복구해 API 재시작 뒤 콜백이 `invalid_state`로 떨어지지 않는다. callback 실패는 비밀값 없는 reason/status로 남기고, 로그인 화면도 token/state/provider/identity 실패 메시지와 reason code를 분리한다. 2026-06-27 18:57 KST에 Tailnet stale Vite `allowedHosts` 재발 상태를 재확인했고, `alpaca-home.taile93291.ts.net`를 Vite 기본 허용 host에도 포함해 수동 Vite 기동 시 403 재발 가능성을 낮췄다. **검증: `https://vignette.chanpaca.net/login` 200, `https://api-vignette.chanpaca.net/health` prod/db/engine true, public Google auth redirect 302 + HttpOnly state cookie, provider callback error → `/login?oauth=access_denied`, `https://alpaca-home.taile93291.ts.net/login` 200 + `/api/health` dev/db/engine true + `/api/auth/config` 200, env 없는 임시 Vite host-header smoke 200, local/Tailnet Google direct는 `local_oauth_unavailable`, local auth E2E 7 passed, Tailnet auth/dev-login E2E 2 passed.**

View file

@ -1,7 +1,7 @@
# 서연(P1) 아바타 작업 핸드오프 — 2026-06-27
> 다른 세션에서 이어서 작업하기 위한 인계. imagegen(gpt-image-2)+BiRefNet+파츠 분리 리깅 파이프라인 전체를 여기서 참조.
> 상세 원칙은 `CLAUDE.md` §4.
> 상세 원칙은 `AGENTS.md` §4.
> 2026-06-27 19:45 추가: 사용자 제공 PSD `라투디 여캐_ver2.psd`를 기준으로 새 파츠 세트 `seoyeon-live2d-psd-v2`를 만들고 P1 실제 세션 기본값으로 연결했다. 이 세트는 컨셉 보드 크롭이 아니라 PSD 레이어 기반이며, `sad` 표정에서 울상 눈썹·우는 입·눈물 파츠를 별도로 합성한다. `p1-concept`는 기본값으로 쓰지 않는다.
@ -47,7 +47,7 @@
→ 결과(base-v2.png)를 사용자에게 보여주고 Image #2 일치 확인.
3. **변주 재생성**: `gen-variants.sh``BASE``base-v2.png`로 바꾸고 실행(동일 7종). → `cut-publish.sh``make-parts.py`.
4. **검증**: dev 서버(5173)의 `/dev/avatar-preview``avatar-shot.mjs`로 캡처 → 사용자 확인. 파츠 정렬이 틀리면 `make-parts.py` 영역 상수(UPPERFACE/EYELID/MOUTH) 튜닝 후 재생성.
5. **검증 게이트**(CLAUDE.md §2): `cd apps/web && npm run typecheck`. 레이아웃 변경 시 `e2e/session-layout.spec.ts`(8/8)+`e2e/layout-visual-gate.spec.ts`(7/7) — **web+api(+DB) 스택 필요**.
5. **검증 게이트**(AGENTS.md §2): `cd apps/web && npm run typecheck`. 레이아웃 변경 시 `e2e/session-layout.spec.ts`(8/8)+`e2e/layout-visual-gate.spec.ts`(7/7) — **web+api(+DB) 스택 필요**.
## 4. 환경 메모
- **dev 서버**: 5173 단일 인스턴스로 띄움(백그라운드). 끊기면 `cd apps/web && npm run dev -- --port 5173 --strictPort`. (5173/5174 중복 인스턴스가 "깨진 화면" 원인이었음 — 항상 5173 단일로.)

View file

@ -0,0 +1,62 @@
#!/usr/bin/env python3
from __future__ import annotations
import argparse
import hashlib
import json
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
API_ROOT = REPO_ROOT / "apps" / "api"
sys.path.insert(0, str(API_ROOT))
from app.services.case_worksheet_rubric import load_rubric, validate_rubric # noqa: E402
from app.session_read_model import case_worksheet_template_item_keys # noqa: E402
def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(description="Validate the case worksheet rubric scaffold.")
parser.add_argument(
"--rubric",
default=str(REPO_ROOT / "data" / "rubrics" / "case-worksheet-rubric.json"),
)
parser.add_argument("--json", action="store_true")
return parser
def main() -> int:
args = build_parser().parse_args()
rubric_path = Path(args.rubric)
report = validate_rubric(
load_rubric(rubric_path),
expected_item_keys=case_worksheet_template_item_keys(),
)
content = rubric_path.read_bytes()
report["rubric_path"] = _display_path(rubric_path)
report["content_sha256"] = hashlib.sha256(content).hexdigest()
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
else:
status = "PASS" if report["passed"] else "FAIL"
print(
f"{status}: {report['rubric_id']} status={report['status']} "
f"sections={report['sections_total']} items={report['items_total']} "
f"scoring_enabled={str(report['scoring_enabled']).lower()}"
)
for warning in report["warnings"]:
print(f"WARN: {warning}")
for error in report["errors"]:
print(f"ERROR: {error}")
return 0 if report["passed"] else 1
def _display_path(path: Path) -> str:
try:
return path.resolve().relative_to(REPO_ROOT).as_posix()
except ValueError:
return str(path)
if __name__ == "__main__":
raise SystemExit(main())

View file

@ -20,13 +20,18 @@ def build_parser() -> argparse.ArgumentParser:
default=str(REPO_ROOT / "data" / "privacy" / "pii-masking-ko-fixtures.json"),
)
parser.add_argument("--json", action="store_true")
parser.add_argument(
"--include-evidence-text",
action="store_true",
help="Include masked text and remaining forbidden strings for local synthetic debugging only.",
)
return parser
def main() -> int:
parser = build_parser()
args = parser.parse_args()
report = evaluate_fixture(Path(args.fixtures))
report = evaluate_fixture(Path(args.fixtures), include_evidence_text=args.include_evidence_text)
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True))
else: