현재 작업 상태 저장
|
|
@ -12,6 +12,8 @@ ENGINE_MODE=claude_cli
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
OPENAI_API_KEY=
|
OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=https://api.openai.com/v1
|
OPENAI_BASE_URL=https://api.openai.com/v1
|
||||||
|
VIGNETTE_VOICE_POC_SAMPLE_TTS=false
|
||||||
|
VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR=
|
||||||
|
|
||||||
# Auth/session. Local dev may enable dev-login; public/prod must not.
|
# Auth/session. Local dev may enable dev-login; public/prod must not.
|
||||||
SESSION_SECRET=dev-insecure-change-me
|
SESSION_SECRET=dev-insecure-change-me
|
||||||
|
|
@ -22,12 +24,14 @@ AUTH_ALLOWED_EMAIL_DOMAINS=["hs.ac.kr","twentyoz.kr"]
|
||||||
AUTH_TEACHER_EMAILS=[]
|
AUTH_TEACHER_EMAILS=[]
|
||||||
AUTH_ADMIN_EMAILS=[]
|
AUTH_ADMIN_EMAILS=[]
|
||||||
AUTH_DEV_LOGIN_ENABLED=true
|
AUTH_DEV_LOGIN_ENABLED=true
|
||||||
|
AUTH_DEV_LOGIN_EXTRA_ORIGINS=[]
|
||||||
DEFAULT_AFFILIATION=
|
DEFAULT_AFFILIATION=
|
||||||
AUTO_SEED_PERSONAS=false
|
AUTO_SEED_PERSONAS=false
|
||||||
ALLOW_SEED_PERSONA_FALLBACK=false
|
ALLOW_SEED_PERSONA_FALLBACK=false
|
||||||
EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false
|
EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false
|
||||||
FRONTEND_BASE_URL=http://localhost:5173
|
FRONTEND_BASE_URL=http://localhost:5173
|
||||||
CORS_ORIGINS=["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"]
|
FRONTEND_ORIGIN_MAP={"api-vignette.chanpaca.net":"https://vignette.chanpaca.net","api-vnet.18ka.net":"https://vnet.18ka.net"}
|
||||||
|
CORS_ORIGINS=["https://vignette.chanpaca.net","https://vnet.18ka.net","https://vignette-b1q.pages.dev","http://localhost:5170","http://localhost:5171","http://localhost:5172","http://localhost:5173","http://localhost:5174","http://localhost:5175","http://localhost:5176","http://localhost:5177","http://localhost:5178","http://localhost:5179","http://localhost:5180","http://127.0.0.1:5170","http://127.0.0.1:5171","http://127.0.0.1:5172","http://127.0.0.1:5173","http://127.0.0.1:5174","http://127.0.0.1:5175","http://127.0.0.1:5176","http://127.0.0.1:5177","http://127.0.0.1:5178","http://127.0.0.1:5179","http://127.0.0.1:5180"]
|
||||||
|
|
||||||
# Live2D runtime. Models must be configured per persona via live2dModelUrl.
|
# Live2D runtime. Models must be configured per persona via live2dModelUrl.
|
||||||
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js
|
VITE_LIVE2D_CUBISM_CORE=/live2d/live2dcubismcore.min.js
|
||||||
|
|
|
||||||
101
AGENTS.md
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
# AGENTS.md — Vignette 프로젝트 작업 지침
|
||||||
|
|
||||||
|
> Vignette = AI 심리상담 시뮬레이션 훈련 플랫폼 (한신대 산학협력).
|
||||||
|
> 모노레포: `apps/api`(FastAPI/Python), `apps/web`(React 19/Vite/Playwright), `docs`, `infra`, `scripts`.
|
||||||
|
|
||||||
|
## 📚 문서 맵 — 작업 전 해당 가이드를 먼저 읽어라
|
||||||
|
|
||||||
|
| 목적 | 문서 |
|
||||||
|
|---|---|
|
||||||
|
| 저장소 개요·빠른 시작 | [`README.md`](./README.md) |
|
||||||
|
| **로컬 서버 띄우기·테스트** | [`docs/guides/local-development.md`](./docs/guides/local-development.md) |
|
||||||
|
| 시스템 아키텍처·데이터 흐름 | [`docs/guides/architecture.md`](./docs/guides/architecture.md) |
|
||||||
|
| 테스트·검증 실행 | [`docs/guides/testing.md`](./docs/guides/testing.md) |
|
||||||
|
| 원천문서·갭 로드맵 | [`docs/guides/source-docs-and-gaps.md`](./docs/guides/source-docs-and-gaps.md) |
|
||||||
|
| **SSOT 상태판** | [`docs/dev_dashboard.html`](./docs/dev_dashboard.html) |
|
||||||
|
| 백로그 | [`docs/ops/backlog-2026-06-26.md`](./docs/ops/backlog-2026-06-26.md) |
|
||||||
|
| **서연 아바타 핸드오프** | [`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) |
|
||||||
|
|
||||||
|
작업 결과로 동작/구조가 바뀌면 해당 가이드와 SSOT 대시보드를 함께 갱신한다.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ 0. 무조건 OS를 먼저 파악하고 시작한다 (최우선·필수)
|
||||||
|
|
||||||
|
**어떤 작업이든 명령을 실행하기 전에 OS와 셸을 먼저 확정하라.** 이걸 건너뛰면
|
||||||
|
경로/인코딩/도구 차이로 시간을 크게 낭비한다(실제로 그랬다).
|
||||||
|
|
||||||
|
작업 시작 시 반드시 확인할 것:
|
||||||
|
|
||||||
|
1. **OS / 셸**: 이 저장소의 주 개발 환경은 **Windows 11 + PowerShell**이다.
|
||||||
|
POSIX를 가정하지 마라. Bash 도구도 쓸 수 있으나 셸마다 문법이 다르다.
|
||||||
|
2. **경로 규칙**: Windows 절대경로(`D:\...`, `C:\...`). 한글·공백 포함 경로가 흔하다
|
||||||
|
(예: OneDrive `문서\카카오톡 받은 파일`). `-LiteralPath`로 다루고, 외부 도구에
|
||||||
|
넘기기 전에 **ASCII 이름으로 로컬 복사**해 인코딩/공백 문제를 차단하라.
|
||||||
|
3. **PowerShell 판(5.1) 주의**: 인라인 `if(){}else{}`를 식으로 못 쓴다(삼항 없음).
|
||||||
|
네이티브 exe stderr를 `2>&1`로 합치지 마라(ErrorRecord로 감싸짐).
|
||||||
|
기본 출력 인코딩은 UTF-16 — 다른 도구가 읽을 파일은 `-Encoding utf8`.
|
||||||
|
4. **외부 CLI는 실제로 블로킹되는지 확인**: GUI 런처(`soffice.exe` 등)는 즉시
|
||||||
|
detach되어 `Start-Process -Wait`가 변환을 안 기다린다. 실제 작업 프로세스
|
||||||
|
(`soffice.bin`)를 직접 호출하라. 좀비 프로세스가 락을 잡으면 정리부터 한다.
|
||||||
|
5. **도구 가용성 먼저 탐지**: 변환/처리 전에 LibreOffice·pandoc·python 라이브러리·
|
||||||
|
Playwright 브라우저 등 무엇이 설치돼 있는지 먼저 확인하고 경로를 잡아라.
|
||||||
|
|
||||||
|
> 한 줄 요약: **"먼저 OS·셸·경로·도구를 확정한 뒤 실행한다."** 추정 금지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 운영 원칙
|
||||||
|
|
||||||
|
- **가짜 증거로 DONE 표기 금지.** 실증/외부 의존/소유자 결정이 필요한 항목은
|
||||||
|
`docs/ops/backlog-*.md`에 분류해 추적한다(B1 코스메틱 · B2 환경제약 · B3 소유자결정 · B4 외부거버넌스).
|
||||||
|
- **`docs/dev_dashboard.html`이 SSOT(단일 진실 공급원)다.** 상태·검증 증거·결정 필요·로드맵의 권위 기준이며, 새 발견·작업 결과·상태 변경은 별도 문서로만 남기지 말고 대시보드에 반영/동기화한다. 백로그(`docs/ops/backlog-*.md`)는 대시보드와 일치시킨다(어긋나면 대시보드 기준).
|
||||||
|
- 소유자(윤찬) 단독 결정 사안을 임의로 정하지 않는다(월권 금지).
|
||||||
|
|
||||||
|
## 2. 검증 기준 (프론트 변경 시)
|
||||||
|
|
||||||
|
- `cd apps/web && npm run typecheck`
|
||||||
|
- 레이아웃 변경은 `e2e/layout-visual-gate.spec.ts`(7/7) + 레이아웃 포커스 E2E +
|
||||||
|
`e2e/session-layout.spec.ts`(8/8) 무회귀. E2E는 web+api(+DB) 스택이 떠 있어야 한다.
|
||||||
|
|
||||||
|
## 3. 커뮤니케이션
|
||||||
|
|
||||||
|
- 모든 대화·주석·커밋 메시지는 한글.
|
||||||
|
- git 커밋 메시지에 Co-Authored-By / Codex 관련 문구 추가 금지.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 이미지 생성(gpt-image-2 = imagegen2) · Live2D식 아바타
|
||||||
|
|
||||||
|
> "이미지 생성해/만들어/그려줘" 요청 → `~/.Codex/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) 참조.
|
||||||
|
|
@ -40,6 +40,10 @@ def _is_allowed_local_dev_cors_origin(value: str) -> bool:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_local_or_forbidden_non_dev_origin(value: str) -> bool:
|
||||||
|
return _is_local_url(value) and not _is_allowed_local_dev_cors_origin(value)
|
||||||
|
|
||||||
|
|
||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
|
|
@ -84,6 +88,14 @@ class Settings(BaseSettings):
|
||||||
default="https://api.openai.com/v1",
|
default="https://api.openai.com/v1",
|
||||||
validation_alias="OPENAI_BASE_URL",
|
validation_alias="OPENAI_BASE_URL",
|
||||||
)
|
)
|
||||||
|
voice_poc_sample_tts_enabled: bool = Field(
|
||||||
|
default=False,
|
||||||
|
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS",
|
||||||
|
)
|
||||||
|
voice_poc_sample_tts_dir: str = Field(
|
||||||
|
default="",
|
||||||
|
validation_alias="VIGNETTE_VOICE_POC_SAMPLE_TTS_DIR",
|
||||||
|
)
|
||||||
|
|
||||||
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
|
# ── 세션/인증 (BFF OAuth 2.1, 토큰 서버 보관) ────────
|
||||||
session_secret: str = Field(
|
session_secret: str = Field(
|
||||||
|
|
@ -143,15 +155,27 @@ class Settings(BaseSettings):
|
||||||
default="http://localhost:5173",
|
default="http://localhost:5173",
|
||||||
validation_alias="FRONTEND_BASE_URL",
|
validation_alias="FRONTEND_BASE_URL",
|
||||||
)
|
)
|
||||||
|
frontend_origin_map: dict[str, str] = Field(
|
||||||
|
default_factory=lambda: {
|
||||||
|
"api-vignette.chanpaca.net": "https://vignette.chanpaca.net",
|
||||||
|
"api-vnet.18ka.net": "https://vnet.18ka.net",
|
||||||
|
},
|
||||||
|
validation_alias="FRONTEND_ORIGIN_MAP",
|
||||||
|
)
|
||||||
|
|
||||||
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
|
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
|
||||||
cors_origins: list[str] = Field(
|
cors_origins: list[str] = Field(
|
||||||
default=[
|
default=[
|
||||||
"https://vignette.chanpaca.net",
|
"https://vignette.chanpaca.net",
|
||||||
|
"https://vnet.18ka.net",
|
||||||
"https://vignette-b1q.pages.dev",
|
"https://vignette-b1q.pages.dev",
|
||||||
],
|
],
|
||||||
validation_alias="CORS_ORIGINS",
|
validation_alias="CORS_ORIGINS",
|
||||||
)
|
)
|
||||||
|
auth_dev_login_extra_origins: list[str] = Field(
|
||||||
|
default=[],
|
||||||
|
validation_alias="AUTH_DEV_LOGIN_EXTRA_ORIGINS",
|
||||||
|
)
|
||||||
|
|
||||||
# Built-in personas are developer/bootstrap fixtures, not runtime truth.
|
# Built-in personas are developer/bootstrap fixtures, not runtime truth.
|
||||||
# Production should use approved rows from app.persona_card only.
|
# Production should use approved rows from app.persona_card only.
|
||||||
|
|
@ -181,6 +205,8 @@ class Settings(BaseSettings):
|
||||||
forbidden.append("AUTO_SEED_PERSONAS")
|
forbidden.append("AUTO_SEED_PERSONAS")
|
||||||
if self.allow_seed_persona_fallback:
|
if self.allow_seed_persona_fallback:
|
||||||
forbidden.append("ALLOW_SEED_PERSONA_FALLBACK")
|
forbidden.append("ALLOW_SEED_PERSONA_FALLBACK")
|
||||||
|
if self.voice_poc_sample_tts_enabled:
|
||||||
|
forbidden.append("VIGNETTE_VOICE_POC_SAMPLE_TTS")
|
||||||
if not self.oauth_google_client_id.strip():
|
if not self.oauth_google_client_id.strip():
|
||||||
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
|
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
|
||||||
if not self.oauth_google_client_secret.strip():
|
if not self.oauth_google_client_secret.strip():
|
||||||
|
|
@ -189,11 +215,13 @@ class Settings(BaseSettings):
|
||||||
forbidden.append("SESSION_SECRET")
|
forbidden.append("SESSION_SECRET")
|
||||||
if _is_local_url(self.frontend_base_url):
|
if _is_local_url(self.frontend_base_url):
|
||||||
forbidden.append("FRONTEND_BASE_URL")
|
forbidden.append("FRONTEND_BASE_URL")
|
||||||
if any(
|
if any(_is_local_or_forbidden_non_dev_origin(origin) for origin in self.cors_origins):
|
||||||
_is_local_url(origin) and not _is_allowed_local_dev_cors_origin(origin)
|
|
||||||
for origin in self.cors_origins
|
|
||||||
):
|
|
||||||
forbidden.append("CORS_ORIGINS")
|
forbidden.append("CORS_ORIGINS")
|
||||||
|
if any(
|
||||||
|
_is_local_or_forbidden_non_dev_origin(origin)
|
||||||
|
for origin in self.frontend_origin_map.values()
|
||||||
|
):
|
||||||
|
forbidden.append("FRONTEND_ORIGIN_MAP")
|
||||||
if forbidden:
|
if forbidden:
|
||||||
joined = ", ".join(forbidden)
|
joined = ", ".join(forbidden)
|
||||||
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
|
raise ValueError(f"{joined} must be production-safe when ENVIRONMENT={self.environment}")
|
||||||
|
|
|
||||||
|
|
@ -221,9 +221,40 @@ def _is_local_origin(origin: str) -> bool:
|
||||||
return host in {"localhost", "127.0.0.1", "::1"}
|
return host in {"localhost", "127.0.0.1", "::1"}
|
||||||
|
|
||||||
|
|
||||||
|
def _frontend_origin_map() -> dict[str, str]:
|
||||||
|
mapped: dict[str, str] = {}
|
||||||
|
for api_host, frontend_origin in settings.frontend_origin_map.items():
|
||||||
|
host = (api_host or "").strip().lower().rstrip(".")
|
||||||
|
origin = _url_origin(frontend_origin)
|
||||||
|
if host and origin:
|
||||||
|
mapped[host] = origin
|
||||||
|
return mapped
|
||||||
|
|
||||||
|
|
||||||
|
def _dev_login_extra_origins() -> set[str]:
|
||||||
|
return {
|
||||||
|
origin
|
||||||
|
for value in settings.auth_dev_login_extra_origins
|
||||||
|
if (origin := _url_origin(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dev_login_extra_hosts() -> set[str]:
|
||||||
|
return {
|
||||||
|
host
|
||||||
|
for origin in _dev_login_extra_origins()
|
||||||
|
if (host := (urlsplit(origin).hostname or "").lower())
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _is_dev_login_allowed_origin(origin: str) -> bool:
|
||||||
|
host = (urlsplit(origin).hostname or "").lower()
|
||||||
|
return _is_local_origin(origin) or origin in _dev_login_extra_origins() or host in _dev_login_extra_hosts()
|
||||||
|
|
||||||
|
|
||||||
def _configured_frontend_origins() -> list[str]:
|
def _configured_frontend_origins() -> list[str]:
|
||||||
origins: list[str] = []
|
origins: list[str] = []
|
||||||
for value in [settings.frontend_base_url, *settings.cors_origins]:
|
for value in [settings.frontend_base_url, *settings.frontend_origin_map.values(), *settings.cors_origins]:
|
||||||
origin = _url_origin(value)
|
origin = _url_origin(value)
|
||||||
if origin and origin not in origins:
|
if origin and origin not in origins:
|
||||||
origins.append(origin)
|
origins.append(origin)
|
||||||
|
|
@ -243,8 +274,8 @@ def _frontend_origin_for_request(request: Request | None = None) -> str:
|
||||||
forwarded_host = request.headers.get("x-forwarded-host")
|
forwarded_host = request.headers.get("x-forwarded-host")
|
||||||
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
||||||
hostname = host.rsplit(":", 1)[0].lower() if host else ""
|
hostname = host.rsplit(":", 1)[0].lower() if host else ""
|
||||||
if hostname == "api-vignette.chanpaca.net":
|
if mapped_origin := _frontend_origin_map().get(hostname):
|
||||||
return "https://vignette.chanpaca.net"
|
return mapped_origin
|
||||||
if hostname in {"localhost", "127.0.0.1", "::1"}:
|
if hostname in {"localhost", "127.0.0.1", "::1"}:
|
||||||
return fallback
|
return fallback
|
||||||
|
|
||||||
|
|
@ -348,15 +379,24 @@ def _dev_login_available(request: Request) -> bool:
|
||||||
if settings.environment != "dev" or not settings.auth_dev_login_enabled:
|
if settings.environment != "dev" or not settings.auth_dev_login_enabled:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
if _dev_login_extra_origins():
|
||||||
|
return True
|
||||||
|
|
||||||
|
saw_browser_origin = False
|
||||||
for header_name in ("origin", "referer"):
|
for header_name in ("origin", "referer"):
|
||||||
origin = _url_origin(request.headers.get(header_name))
|
origin = _url_origin(request.headers.get(header_name))
|
||||||
if origin and not _is_local_origin(origin):
|
if origin:
|
||||||
return False
|
saw_browser_origin = True
|
||||||
|
if not _is_dev_login_allowed_origin(origin):
|
||||||
|
return False
|
||||||
|
|
||||||
|
if not saw_browser_origin and _dev_login_extra_origins():
|
||||||
|
return True
|
||||||
|
|
||||||
forwarded_host = request.headers.get("x-forwarded-host")
|
forwarded_host = request.headers.get("x-forwarded-host")
|
||||||
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
||||||
origin = _url_origin(f"http://{host}") if host else None
|
origin = _url_origin(f"http://{host}") if host else None
|
||||||
return bool(origin and _is_local_origin(origin))
|
return bool(origin and _is_dev_login_allowed_origin(origin))
|
||||||
|
|
||||||
|
|
||||||
@router.get("/config", response_model=AuthConfigResponse)
|
@router.get("/config", response_model=AuthConfigResponse)
|
||||||
|
|
|
||||||
|
|
@ -381,8 +381,14 @@ async def _load_session_or_404(
|
||||||
principal: Principal,
|
principal: Principal,
|
||||||
*,
|
*,
|
||||||
allow_ended: bool = False,
|
allow_ended: bool = False,
|
||||||
|
include_turn_evaluation: bool = False,
|
||||||
) -> InProcSession:
|
) -> InProcSession:
|
||||||
sess = await session_persistence.load_session(session_id, principal, allow_ended=True)
|
sess = await session_persistence.load_session(
|
||||||
|
session_id,
|
||||||
|
principal,
|
||||||
|
allow_ended=True,
|
||||||
|
include_turn_evaluation=include_turn_evaluation,
|
||||||
|
)
|
||||||
if sess is not None:
|
if sess is not None:
|
||||||
store.put(sess)
|
store.put(sess)
|
||||||
elif runtime_fallback_allowed():
|
elif runtime_fallback_allowed():
|
||||||
|
|
@ -874,7 +880,11 @@ async def get_session_detail(
|
||||||
) -> SessionDetailResponse:
|
) -> SessionDetailResponse:
|
||||||
"""Return a learner-owned session with transcript for resume/history."""
|
"""Return a learner-owned session with transcript for resume/history."""
|
||||||
_ensure_learner(principal)
|
_ensure_learner(principal)
|
||||||
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
|
sess = await _load_session_or_404(
|
||||||
|
session_id,
|
||||||
|
principal,
|
||||||
|
allow_ended=True,
|
||||||
|
)
|
||||||
return _session_detail(sess, review_ready=await _review_ready(sess, principal))
|
return _session_detail(sess, review_ready=await _review_ready(sess, principal))
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -951,7 +961,12 @@ async def get_session_review(
|
||||||
) -> SessionReviewResponse:
|
) -> SessionReviewResponse:
|
||||||
"""Return a learner-safe review built only from the stored session transcript."""
|
"""Return a learner-safe review built only from the stored session transcript."""
|
||||||
_ensure_learner(principal)
|
_ensure_learner(principal)
|
||||||
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
|
sess = await _load_session_or_404(
|
||||||
|
session_id,
|
||||||
|
principal,
|
||||||
|
allow_ended=True,
|
||||||
|
include_turn_evaluation=True,
|
||||||
|
)
|
||||||
visible_turns = _learner_visible_turns(sess)
|
visible_turns = _learner_visible_turns(sess)
|
||||||
hidden_turns = len(visible_turns) != len(sess.turns)
|
hidden_turns = len(visible_turns) != len(sess.turns)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -49,11 +49,13 @@ _MAX_AUDIO_BYTES = 10 * 1024 * 1024
|
||||||
async def voice_health() -> JSONResponse:
|
async def voice_health() -> JSONResponse:
|
||||||
"""Return voice service readiness."""
|
"""Return voice service readiness."""
|
||||||
available = voice_service.is_available()
|
available = voice_service.is_available()
|
||||||
|
tts_provider = voice_service.tts_provider()
|
||||||
body = {
|
body = {
|
||||||
"status": "ok" if available else "degraded",
|
"status": "ok" if available else "degraded",
|
||||||
"available": available,
|
"available": available,
|
||||||
"stt_model": voice_svc.STT_MODEL,
|
"stt_model": voice_svc.STT_MODEL,
|
||||||
"tts_model": voice_svc.TTS_MODEL,
|
"tts_model": voice_svc.TTS_MODEL,
|
||||||
|
"tts_provider": tts_provider,
|
||||||
"reason": None if available else "OPENAI_API_KEY is not configured",
|
"reason": None if available else "OPENAI_API_KEY is not configured",
|
||||||
}
|
}
|
||||||
return JSONResponse(body, status_code=200 if available else 503)
|
return JSONResponse(body, status_code=200 if available else 503)
|
||||||
|
|
@ -108,6 +110,7 @@ async def voice_ws(websocket: WebSocket) -> None:
|
||||||
"session_id": session_id,
|
"session_id": session_id,
|
||||||
"voice": voice_preset.openai_voice,
|
"voice": voice_preset.openai_voice,
|
||||||
"preset": voice_preset.preset,
|
"preset": voice_preset.preset,
|
||||||
|
"tts_provider": voice_service.tts_provider(),
|
||||||
"state": "idle",
|
"state": "idle",
|
||||||
**bind_meta,
|
**bind_meta,
|
||||||
},
|
},
|
||||||
|
|
@ -374,7 +377,12 @@ async def _run_turn_and_speak(
|
||||||
# TTS speaking state comes before chunk metadata and binary audio.
|
# TTS speaking state comes before chunk metadata and binary audio.
|
||||||
await _safe_send_json(
|
await _safe_send_json(
|
||||||
websocket,
|
websocket,
|
||||||
{"type": "state", "state": "speaking", "voice": voice_preset.openai_voice},
|
{
|
||||||
|
"type": "state",
|
||||||
|
"state": "speaking",
|
||||||
|
"voice": voice_preset.openai_voice,
|
||||||
|
"tts_provider": voice_service.tts_provider(),
|
||||||
|
},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
n = 0
|
n = 0
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
from typing import AsyncIterator, Optional
|
from typing import AsyncIterator, Optional
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
|
@ -49,6 +50,32 @@ TTS_RESPONSE_FORMAT = "mp3"
|
||||||
# End-of-turn readiness default for cascaded STT providers.
|
# End-of-turn readiness default for cascaded STT providers.
|
||||||
EOT_SILENCE_THRESHOLD_MS = 1200
|
EOT_SILENCE_THRESHOLD_MS = 1200
|
||||||
|
|
||||||
|
_REPO_ROOT = Path(__file__).resolve().parents[4]
|
||||||
|
POC_SAMPLE_TTS_PRESET = "soft-young-fem"
|
||||||
|
POC_SAMPLE_TTS_DEFAULT_DIR = (
|
||||||
|
_REPO_ROOT / "docs" / "voice-art" / "p1-seoyeon-higgs-v3-20260627"
|
||||||
|
)
|
||||||
|
POC_SAMPLE_TTS_CHUNK_SIZE = 4096
|
||||||
|
_POC_SAMPLE_TTS_DEFAULT_SAMPLE = "p1_seoyeon_01_depressed_slow"
|
||||||
|
_POC_SAMPLE_TTS_KEYWORDS: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||||
|
(
|
||||||
|
"p1_seoyeon_03_anxious_guarded",
|
||||||
|
("엄마", "비밀", "말하지", "불안", "무서", "걱정", "들키", "싫", "갈래"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"p1_seoyeon_02_tired_flat",
|
||||||
|
("잠", "피곤", "무거", "아무것도", "지쳐", "힘들", "에너지"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"p1_seoyeon_05_recovered_lively",
|
||||||
|
("오늘은", "친구", "웃", "괜찮았", "좋았", "해냈"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"p1_seoyeon_04_rapport_relief",
|
||||||
|
("괜찮", "들어", "고마", "선생님", "편해", "조금", "말해"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
|
# OpenAI 공식 voice 풀(2026 기준): alloy, ash, ballad, coral, echo, fable,
|
||||||
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
|
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
|
||||||
_OPENAI_VOICES = {
|
_OPENAI_VOICES = {
|
||||||
|
|
@ -240,9 +267,32 @@ def assess_end_of_turn(
|
||||||
class VoiceService:
|
class VoiceService:
|
||||||
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
|
"""OpenAI STT/TTS 어댑터. 앱 수명주기 동안 1 인스턴스 재사용(httpx 풀 공유)."""
|
||||||
|
|
||||||
def __init__(self, api_key: Optional[str] = None, base_url: Optional[str] = None) -> None:
|
def __init__(
|
||||||
|
self,
|
||||||
|
api_key: Optional[str] = None,
|
||||||
|
base_url: Optional[str] = None,
|
||||||
|
*,
|
||||||
|
poc_sample_tts_enabled: Optional[bool] = None,
|
||||||
|
environment: Optional[str] = None,
|
||||||
|
poc_sample_tts_dir: Optional[str | Path] = None,
|
||||||
|
) -> None:
|
||||||
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
|
self._api_key = (api_key if api_key is not None else settings.openai_api_key) or ""
|
||||||
self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
|
self._base_url = (base_url or settings.openai_base_url or OPENAI_BASE_URL).rstrip("/")
|
||||||
|
self._environment = environment if environment is not None else settings.environment
|
||||||
|
self._poc_sample_tts_enabled = (
|
||||||
|
bool(settings.voice_poc_sample_tts_enabled)
|
||||||
|
if poc_sample_tts_enabled is None
|
||||||
|
else bool(poc_sample_tts_enabled)
|
||||||
|
)
|
||||||
|
sample_dir_value: str | Path = (
|
||||||
|
poc_sample_tts_dir
|
||||||
|
if poc_sample_tts_dir is not None
|
||||||
|
else (settings.voice_poc_sample_tts_dir or POC_SAMPLE_TTS_DEFAULT_DIR)
|
||||||
|
)
|
||||||
|
sample_dir = Path(sample_dir_value)
|
||||||
|
if not sample_dir.is_absolute():
|
||||||
|
sample_dir = _REPO_ROOT / sample_dir
|
||||||
|
self._poc_sample_tts_dir = sample_dir
|
||||||
self._client: Optional[httpx.AsyncClient] = None
|
self._client: Optional[httpx.AsyncClient] = None
|
||||||
|
|
||||||
# ── 수명주기 ──────────────────────────────────────────
|
# ── 수명주기 ──────────────────────────────────────────
|
||||||
|
|
@ -264,6 +314,35 @@ class VoiceService:
|
||||||
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
|
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
|
||||||
return bool(self._api_key)
|
return bool(self._api_key)
|
||||||
|
|
||||||
|
def tts_provider(self) -> str:
|
||||||
|
if self._poc_sample_tts_available():
|
||||||
|
return "p1-sample-poc"
|
||||||
|
if self._api_key:
|
||||||
|
return "openai"
|
||||||
|
if self._poc_sample_tts_enabled and self._environment != "dev":
|
||||||
|
return "disabled-non-dev"
|
||||||
|
return "unavailable"
|
||||||
|
|
||||||
|
def poc_sample_tts_available(self) -> bool:
|
||||||
|
return self._poc_sample_tts_available()
|
||||||
|
|
||||||
|
def _poc_sample_tts_available(self) -> bool:
|
||||||
|
return (
|
||||||
|
self._poc_sample_tts_enabled
|
||||||
|
and self._environment == "dev"
|
||||||
|
and self._poc_sample_path(_POC_SAMPLE_TTS_DEFAULT_SAMPLE).is_file()
|
||||||
|
)
|
||||||
|
|
||||||
|
def _should_use_poc_sample_tts(self, voice: VoicePreset) -> bool:
|
||||||
|
return (
|
||||||
|
self._poc_sample_tts_enabled
|
||||||
|
and self._environment == "dev"
|
||||||
|
and voice.preset == POC_SAMPLE_TTS_PRESET
|
||||||
|
)
|
||||||
|
|
||||||
|
def _poc_sample_path(self, sample_id: str) -> Path:
|
||||||
|
return self._poc_sample_tts_dir / f"{sample_id}.mp3"
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def _http(self) -> httpx.AsyncClient:
|
def _http(self) -> httpx.AsyncClient:
|
||||||
if not self._api_key:
|
if not self._api_key:
|
||||||
|
|
@ -341,6 +420,10 @@ class VoiceService:
|
||||||
text = speakable_text(text)
|
text = speakable_text(text)
|
||||||
if not text:
|
if not text:
|
||||||
return
|
return
|
||||||
|
if self._should_use_poc_sample_tts(voice):
|
||||||
|
async for chunk in self._synthesize_poc_sample_tts(text):
|
||||||
|
yield chunk
|
||||||
|
return
|
||||||
payload = build_tts_payload(
|
payload = build_tts_payload(
|
||||||
text,
|
text,
|
||||||
voice,
|
voice,
|
||||||
|
|
@ -375,6 +458,25 @@ class VoiceService:
|
||||||
except httpx.HTTPError as e:
|
except httpx.HTTPError as e:
|
||||||
raise RuntimeError(f"TTS transport error: {e}") from e
|
raise RuntimeError(f"TTS transport error: {e}") from e
|
||||||
|
|
||||||
|
async def _synthesize_poc_sample_tts(self, text: str) -> AsyncIterator[TTSChunk]:
|
||||||
|
sample_id = self._select_poc_sample_id(text)
|
||||||
|
sample_path = self._poc_sample_path(sample_id)
|
||||||
|
try:
|
||||||
|
data = sample_path.read_bytes()
|
||||||
|
except OSError as e:
|
||||||
|
raise VoiceUnavailable(f"P1 sample TTS asset is missing: {sample_path}") from e
|
||||||
|
for i in range(0, len(data), POC_SAMPLE_TTS_CHUNK_SIZE):
|
||||||
|
chunk = data[i : i + POC_SAMPLE_TTS_CHUNK_SIZE]
|
||||||
|
if chunk:
|
||||||
|
yield TTSChunk(audio=chunk)
|
||||||
|
|
||||||
|
def _select_poc_sample_id(self, text: str) -> str:
|
||||||
|
normalized = text.casefold()
|
||||||
|
for sample_id, keywords in _POC_SAMPLE_TTS_KEYWORDS:
|
||||||
|
if any(keyword.casefold() in normalized for keyword in keywords):
|
||||||
|
return sample_id
|
||||||
|
return _POC_SAMPLE_TTS_DEFAULT_SAMPLE
|
||||||
|
|
||||||
async def _synthesize_fallback(self, payload: dict[str, object]) -> AsyncIterator[TTSChunk]:
|
async def _synthesize_fallback(self, payload: dict[str, object]) -> AsyncIterator[TTSChunk]:
|
||||||
"""tts-1 폴백(비스트림 POST → 전체 바이트를 청크로 분할)."""
|
"""tts-1 폴백(비스트림 POST → 전체 바이트를 청크로 분할)."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,11 @@ from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
||||||
|
|
||||||
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||||
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
||||||
|
_APPROPRIATENESS_SCORE = {
|
||||||
|
"warn": 1.0,
|
||||||
|
"neutral": 3.0,
|
||||||
|
"pos": 5.0,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
_JOINED_CARD_COLUMNS = (
|
_JOINED_CARD_COLUMNS = (
|
||||||
|
|
@ -71,6 +76,248 @@ def _stage(stage: object) -> str:
|
||||||
return getattr(stage, "value", str(stage))
|
return getattr(stage, "value", str(stage))
|
||||||
|
|
||||||
|
|
||||||
|
def _clean_text(value: Any) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
text = str(value).strip()
|
||||||
|
return text or None
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_float(value: Any) -> float | None:
|
||||||
|
if isinstance(value, (int, float)):
|
||||||
|
return float(value)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluation_loop(evaluation: dict[str, Any]) -> str:
|
||||||
|
loop = _clean_text(evaluation.get("loop")) or "fast"
|
||||||
|
return loop if loop in {"fast", "deep"} else "fast"
|
||||||
|
|
||||||
|
|
||||||
|
def _dict_items(value: Any) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(value, list):
|
||||||
|
return []
|
||||||
|
return [item for item in value if isinstance(item, dict)]
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluation_feedback_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
|
"""Normalize scalar/rationale turn-evaluation fields into feedback_scores rows."""
|
||||||
|
if not isinstance(evaluation, dict):
|
||||||
|
return []
|
||||||
|
loop = _evaluation_loop(evaluation)
|
||||||
|
rows: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
def add(
|
||||||
|
dimension: str,
|
||||||
|
*,
|
||||||
|
score: float | None = None,
|
||||||
|
rationale: str | None = None,
|
||||||
|
top1_score: float | None = None,
|
||||||
|
) -> None:
|
||||||
|
dim = _clean_text(dimension)
|
||||||
|
if not dim:
|
||||||
|
return
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"dimension": dim,
|
||||||
|
"score": score,
|
||||||
|
"rationale": rationale,
|
||||||
|
"top1_score": top1_score,
|
||||||
|
"loop": loop,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
appropriateness = _clean_text(evaluation.get("appropriateness")) or "neutral"
|
||||||
|
if appropriateness not in _APPROPRIATENESS_SCORE:
|
||||||
|
appropriateness = "neutral"
|
||||||
|
add(
|
||||||
|
"appropriateness",
|
||||||
|
score=_APPROPRIATENESS_SCORE[appropriateness],
|
||||||
|
rationale=_clean_text(evaluation.get("appropriateness_note")),
|
||||||
|
)
|
||||||
|
|
||||||
|
rapport = _safe_float(evaluation.get("rapport_signal"))
|
||||||
|
if rapport is not None:
|
||||||
|
add("rapport_signal", score=max(-1.0, min(1.0, rapport)))
|
||||||
|
|
||||||
|
theory_mode = _clean_text(evaluation.get("theory_mode"))
|
||||||
|
if theory_mode:
|
||||||
|
add("theory_mode", rationale=theory_mode)
|
||||||
|
|
||||||
|
error = _clean_text(evaluation.get("error"))
|
||||||
|
if error:
|
||||||
|
add("error", rationale=error)
|
||||||
|
|
||||||
|
for tag in _dict_items(evaluation.get("techniques")):
|
||||||
|
code = _clean_text(tag.get("code"))
|
||||||
|
rationale = _clean_text(tag.get("rationale"))
|
||||||
|
if code and rationale:
|
||||||
|
add(f"technique:{code}", rationale=rationale)
|
||||||
|
|
||||||
|
for state in _dict_items(evaluation.get("client_state_read")):
|
||||||
|
code = _clean_text(state.get("code"))
|
||||||
|
rationale = _clean_text(state.get("rationale"))
|
||||||
|
if code and rationale:
|
||||||
|
add(f"client_state:{code}", rationale=rationale)
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluation_technique_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(evaluation, dict):
|
||||||
|
return []
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
for tag in _dict_items(evaluation.get("techniques")):
|
||||||
|
code = _clean_text(tag.get("code"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"label_ko": _clean_text(tag.get("label_ko")) or code,
|
||||||
|
"category": _clean_text(tag.get("category")) or "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluation_client_state_rows(evaluation: dict[str, Any] | None) -> list[dict[str, str]]:
|
||||||
|
if not isinstance(evaluation, dict):
|
||||||
|
return []
|
||||||
|
rows: list[dict[str, str]] = []
|
||||||
|
for state in _dict_items(evaluation.get("client_state_read")):
|
||||||
|
code = _clean_text(state.get("code"))
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"code": code,
|
||||||
|
"label_ko": _clean_text(state.get("label_ko")) or code,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluation_comment_rows(evaluation: dict[str, Any] | None) -> list[dict[str, Any]]:
|
||||||
|
if not isinstance(evaluation, dict):
|
||||||
|
return []
|
||||||
|
deviation = evaluation.get("intent_deviation")
|
||||||
|
if not isinstance(deviation, dict):
|
||||||
|
return []
|
||||||
|
note = _clean_text(evaluation.get("appropriateness_note")) or "의도와 다른 부분"
|
||||||
|
return [{"kind": "critique", "text": note, "intent_deviation": deviation}]
|
||||||
|
|
||||||
|
|
||||||
|
def _appropriateness_from_score(score: Any) -> str:
|
||||||
|
value = _safe_float(score)
|
||||||
|
if value is None:
|
||||||
|
return "neutral"
|
||||||
|
if value >= 4.0:
|
||||||
|
return "pos"
|
||||||
|
if value <= 2.0:
|
||||||
|
return "warn"
|
||||||
|
return "neutral"
|
||||||
|
|
||||||
|
|
||||||
|
def _base_turn_evaluation(turn_seq: int, stage: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"loop": "fast",
|
||||||
|
"turn_seq": turn_seq,
|
||||||
|
"stage": stage,
|
||||||
|
"techniques": [],
|
||||||
|
"client_state_read": [],
|
||||||
|
"appropriateness": "neutral",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_turn_evaluations(
|
||||||
|
turn_refs: list[tuple[str, int, str]],
|
||||||
|
*,
|
||||||
|
feedback_rows: Iterable[Any],
|
||||||
|
technique_rows: Iterable[Any],
|
||||||
|
client_state_rows: Iterable[Any],
|
||||||
|
comment_rows: Iterable[Any],
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Rehydrate normalized DB rows back into the TurnRecord.evaluation shape."""
|
||||||
|
refs = {turn_id: (turn_seq, stage) for turn_id, turn_seq, stage in turn_refs}
|
||||||
|
evaluations: dict[str, dict[str, Any]] = {}
|
||||||
|
rationale_by_dimension: dict[str, dict[str, str]] = {}
|
||||||
|
|
||||||
|
def ensure(turn_id: str) -> dict[str, Any]:
|
||||||
|
if turn_id not in evaluations:
|
||||||
|
turn_seq, stage = refs[turn_id]
|
||||||
|
evaluations[turn_id] = _base_turn_evaluation(turn_seq, stage)
|
||||||
|
return evaluations[turn_id]
|
||||||
|
|
||||||
|
for row in feedback_rows:
|
||||||
|
turn_id = str(row["turn_id"])
|
||||||
|
if turn_id not in refs:
|
||||||
|
continue
|
||||||
|
ev = ensure(turn_id)
|
||||||
|
loop = _clean_text(row["loop"])
|
||||||
|
if loop in {"fast", "deep"}:
|
||||||
|
ev["loop"] = loop
|
||||||
|
dimension = _clean_text(row["dimension"]) or ""
|
||||||
|
rationale = _clean_text(row["rationale"])
|
||||||
|
if rationale:
|
||||||
|
rationale_by_dimension.setdefault(turn_id, {})[dimension] = rationale
|
||||||
|
if dimension == "appropriateness":
|
||||||
|
ev["appropriateness"] = _appropriateness_from_score(row["score"])
|
||||||
|
if rationale:
|
||||||
|
ev["appropriateness_note"] = rationale
|
||||||
|
elif dimension == "rapport_signal":
|
||||||
|
score = _safe_float(row["score"])
|
||||||
|
if score is not None:
|
||||||
|
ev["rapport_signal"] = max(-1.0, min(1.0, score))
|
||||||
|
elif dimension == "theory_mode" and rationale:
|
||||||
|
ev["theory_mode"] = rationale
|
||||||
|
elif dimension == "error" and rationale:
|
||||||
|
ev["error"] = rationale
|
||||||
|
|
||||||
|
for row in technique_rows:
|
||||||
|
turn_id = str(row["turn_id"])
|
||||||
|
if turn_id not in refs:
|
||||||
|
continue
|
||||||
|
code = _clean_text(row["code"])
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
item = {
|
||||||
|
"code": code,
|
||||||
|
"label_ko": _clean_text(row["label_ko"]) or code,
|
||||||
|
"category": _clean_text(row["category"]) or "",
|
||||||
|
}
|
||||||
|
rationale = rationale_by_dimension.get(turn_id, {}).get(f"technique:{code}")
|
||||||
|
if rationale:
|
||||||
|
item["rationale"] = rationale
|
||||||
|
ensure(turn_id)["techniques"].append(item)
|
||||||
|
|
||||||
|
for row in client_state_rows:
|
||||||
|
turn_id = str(row["turn_id"])
|
||||||
|
if turn_id not in refs:
|
||||||
|
continue
|
||||||
|
code = _clean_text(row["code"])
|
||||||
|
if not code:
|
||||||
|
continue
|
||||||
|
item = {
|
||||||
|
"code": code,
|
||||||
|
"label_ko": _clean_text(row["label_ko"]) or code,
|
||||||
|
}
|
||||||
|
rationale = rationale_by_dimension.get(turn_id, {}).get(f"client_state:{code}")
|
||||||
|
if rationale:
|
||||||
|
item["rationale"] = rationale
|
||||||
|
ensure(turn_id)["client_state_read"].append(item)
|
||||||
|
|
||||||
|
for row in comment_rows:
|
||||||
|
turn_id = str(row["turn_id"])
|
||||||
|
if turn_id not in refs:
|
||||||
|
continue
|
||||||
|
deviation = row["intent_deviation"]
|
||||||
|
if isinstance(deviation, dict):
|
||||||
|
ensure(turn_id)["intent_deviation"] = deviation
|
||||||
|
|
||||||
|
return evaluations
|
||||||
|
|
||||||
|
|
||||||
async def _record_session_read_audit(
|
async def _record_session_read_audit(
|
||||||
conn: Any,
|
conn: Any,
|
||||||
principal: Principal,
|
principal: Principal,
|
||||||
|
|
@ -113,7 +360,7 @@ def _state_from_row(row, card: PersonaCard) -> state_machine.SessionState:
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _turn_from_row(row) -> TurnRecord:
|
def _turn_from_row(row, evaluation: dict[str, Any] | None = None) -> TurnRecord:
|
||||||
created_at = _ts(row["created_at"]) or time.time()
|
created_at = _ts(row["created_at"]) or time.time()
|
||||||
return TurnRecord(
|
return TurnRecord(
|
||||||
turn_seq=int(row["seq"]),
|
turn_seq=int(row["seq"]),
|
||||||
|
|
@ -121,6 +368,7 @@ def _turn_from_row(row) -> TurnRecord:
|
||||||
stage=row["stage"],
|
stage=row["stage"],
|
||||||
text=row["text"] or row["text_masked"] or "",
|
text=row["text"] or row["text_masked"] or "",
|
||||||
text_masked=row["text_masked"] or row["text"] or "",
|
text_masked=row["text_masked"] or row["text"] or "",
|
||||||
|
turn_id=str(_row_value(row, "id")) if _row_value(row, "id") is not None else None,
|
||||||
created_at=created_at,
|
created_at=created_at,
|
||||||
llm_provider=_row_value(row, "llm_provider"),
|
llm_provider=_row_value(row, "llm_provider"),
|
||||||
model=_row_value(row, "model"),
|
model=_row_value(row, "model"),
|
||||||
|
|
@ -131,10 +379,179 @@ def _turn_from_row(row) -> TurnRecord:
|
||||||
silence_ms=_row_value(row, "silence_ms"),
|
silence_ms=_row_value(row, "silence_ms"),
|
||||||
speech_rate=_row_value(row, "speech_rate"),
|
speech_rate=_row_value(row, "speech_rate"),
|
||||||
barge_in=_row_value(row, "barge_in"),
|
barge_in=_row_value(row, "barge_in"),
|
||||||
|
evaluation=evaluation,
|
||||||
visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO),
|
visible_to=tuple(_row_value(row, "visible_to") or DEFAULT_TURN_VISIBLE_TO),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _persist_turn_evaluation(conn: Any, turn_id: str, evaluation: dict[str, Any] | None) -> None:
|
||||||
|
if not isinstance(evaluation, dict):
|
||||||
|
return
|
||||||
|
await conn.execute("SELECT set_config('app.ai_context', '1', true)")
|
||||||
|
await conn.execute("SELECT set_config('app.current_ai_view', 'evaluator', true)")
|
||||||
|
await conn.execute("SELECT set_config('app.current_sens_max', '2', true)")
|
||||||
|
|
||||||
|
for row in _evaluation_feedback_rows(evaluation):
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.feedback_scores (
|
||||||
|
turn_id, dimension, score, rationale, top1_score, loop
|
||||||
|
)
|
||||||
|
VALUES ($1::uuid, $2, $3, $4, $5, $6)
|
||||||
|
ON CONFLICT (turn_id, dimension) DO UPDATE SET
|
||||||
|
score = EXCLUDED.score,
|
||||||
|
rationale = EXCLUDED.rationale,
|
||||||
|
top1_score = EXCLUDED.top1_score,
|
||||||
|
loop = EXCLUDED.loop
|
||||||
|
""",
|
||||||
|
turn_id,
|
||||||
|
row["dimension"],
|
||||||
|
row["score"],
|
||||||
|
row["rationale"],
|
||||||
|
row["top1_score"],
|
||||||
|
row["loop"],
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in _evaluation_technique_rows(evaluation):
|
||||||
|
label_id = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.technique_label_def (code, display_name, category)
|
||||||
|
VALUES ($1, $2, $3)
|
||||||
|
ON CONFLICT (code, version) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
category = EXCLUDED.category,
|
||||||
|
is_active = TRUE
|
||||||
|
RETURNING label_id
|
||||||
|
""",
|
||||||
|
row["code"],
|
||||||
|
row["label_ko"],
|
||||||
|
row["category"],
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.turn_technique (turn_id, label_id)
|
||||||
|
VALUES ($1::uuid, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
turn_id,
|
||||||
|
label_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in _evaluation_client_state_rows(evaluation):
|
||||||
|
label_id = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.client_state_def (code, display_name)
|
||||||
|
VALUES ($1, $2)
|
||||||
|
ON CONFLICT (code, version) DO UPDATE SET
|
||||||
|
display_name = EXCLUDED.display_name,
|
||||||
|
is_active = TRUE
|
||||||
|
RETURNING label_id
|
||||||
|
""",
|
||||||
|
row["code"],
|
||||||
|
row["label_ko"],
|
||||||
|
)
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.turn_client_state (turn_id, label_id)
|
||||||
|
VALUES ($1::uuid, $2)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
""",
|
||||||
|
turn_id,
|
||||||
|
label_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
for row in _evaluation_comment_rows(evaluation):
|
||||||
|
await conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO app.supervisor_comment (
|
||||||
|
turn_id, kind, text, intent_deviation
|
||||||
|
)
|
||||||
|
VALUES ($1::uuid, $2, $3, $4::jsonb)
|
||||||
|
""",
|
||||||
|
turn_id,
|
||||||
|
row["kind"],
|
||||||
|
row["text"],
|
||||||
|
row["intent_deviation"],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _load_turn_evaluations(
|
||||||
|
conn: Any,
|
||||||
|
turn_refs: list[tuple[str, int, str]],
|
||||||
|
) -> dict[str, dict[str, Any]]:
|
||||||
|
turn_ids = [turn_id for turn_id, _, _ in turn_refs]
|
||||||
|
if not turn_ids:
|
||||||
|
return {}
|
||||||
|
feedback_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT turn_id::text AS turn_id, dimension, score, rationale, top1_score, loop
|
||||||
|
FROM app.feedback_scores
|
||||||
|
WHERE turn_id = ANY($1::uuid[])
|
||||||
|
ORDER BY created_at, dimension
|
||||||
|
""",
|
||||||
|
turn_ids,
|
||||||
|
)
|
||||||
|
technique_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
tt.turn_id::text AS turn_id,
|
||||||
|
d.code,
|
||||||
|
d.display_name AS label_ko,
|
||||||
|
d.category
|
||||||
|
FROM app.turn_technique tt
|
||||||
|
JOIN app.technique_label_def d ON d.label_id = tt.label_id
|
||||||
|
WHERE tt.turn_id = ANY($1::uuid[])
|
||||||
|
ORDER BY tt.turn_id, d.code
|
||||||
|
""",
|
||||||
|
turn_ids,
|
||||||
|
)
|
||||||
|
client_state_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
ts.turn_id::text AS turn_id,
|
||||||
|
d.code,
|
||||||
|
d.display_name AS label_ko
|
||||||
|
FROM app.turn_client_state ts
|
||||||
|
JOIN app.client_state_def d ON d.label_id = ts.label_id
|
||||||
|
WHERE ts.turn_id = ANY($1::uuid[])
|
||||||
|
ORDER BY ts.turn_id, d.code
|
||||||
|
""",
|
||||||
|
turn_ids,
|
||||||
|
)
|
||||||
|
comment_rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
SELECT turn_id::text AS turn_id, intent_deviation
|
||||||
|
FROM app.supervisor_comment
|
||||||
|
WHERE turn_id = ANY($1::uuid[])
|
||||||
|
AND intent_deviation IS NOT NULL
|
||||||
|
ORDER BY created_at
|
||||||
|
""",
|
||||||
|
turn_ids,
|
||||||
|
)
|
||||||
|
return _rebuild_turn_evaluations(
|
||||||
|
turn_refs,
|
||||||
|
feedback_rows=feedback_rows,
|
||||||
|
technique_rows=technique_rows,
|
||||||
|
client_state_rows=client_state_rows,
|
||||||
|
comment_rows=comment_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _hydrate_session_turn_evaluations(sess: InProcSession) -> None:
|
||||||
|
turn_refs = [
|
||||||
|
(turn.turn_id, turn.turn_seq, turn.stage)
|
||||||
|
for turn in sess.turns
|
||||||
|
if turn.turn_id is not None
|
||||||
|
]
|
||||||
|
if not turn_refs:
|
||||||
|
return
|
||||||
|
async with acquire(ai_view="evaluator") as conn:
|
||||||
|
evaluations = await _load_turn_evaluations(conn, turn_refs)
|
||||||
|
for turn in sess.turns:
|
||||||
|
if turn.turn_id and turn.turn_id in evaluations:
|
||||||
|
turn.evaluation = evaluations[turn.turn_id]
|
||||||
|
|
||||||
|
|
||||||
async def ensure_review_tables() -> None:
|
async def ensure_review_tables() -> None:
|
||||||
"""Create runtime review/evaluation storage when the DB role allows it."""
|
"""Create runtime review/evaluation storage when the DB role allows it."""
|
||||||
try:
|
try:
|
||||||
|
|
@ -441,6 +858,7 @@ async def load_session(
|
||||||
principal: Principal,
|
principal: Principal,
|
||||||
*,
|
*,
|
||||||
allow_ended: bool = False,
|
allow_ended: bool = False,
|
||||||
|
include_turn_evaluation: bool = False,
|
||||||
) -> InProcSession | None:
|
) -> InProcSession | None:
|
||||||
try:
|
try:
|
||||||
get_pool()
|
get_pool()
|
||||||
|
|
@ -496,7 +914,7 @@ async def load_session(
|
||||||
)
|
)
|
||||||
turn_rows = await conn.fetch(
|
turn_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT seq, speaker, stage, text, text_masked, created_at,
|
SELECT id, seq, speaker, stage, text, text_masked, created_at,
|
||||||
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
||||||
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
||||||
FROM app.turns
|
FROM app.turns
|
||||||
|
|
@ -518,7 +936,9 @@ async def load_session(
|
||||||
"learner_id": sess.learner_id,
|
"learner_id": sess.learner_id,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
return sess
|
if sess is not None and include_turn_evaluation:
|
||||||
|
await _hydrate_session_turn_evaluations(sess)
|
||||||
|
return sess
|
||||||
except Exception:
|
except Exception:
|
||||||
require_runtime_fallback_allowed("session load")
|
require_runtime_fallback_allowed("session load")
|
||||||
return None
|
return None
|
||||||
|
|
@ -546,7 +966,7 @@ async def append_turn(
|
||||||
)
|
)
|
||||||
or 1
|
or 1
|
||||||
)
|
)
|
||||||
await conn.execute(
|
inserted_turn_id = await conn.fetchval(
|
||||||
"""
|
"""
|
||||||
INSERT INTO app.turns (
|
INSERT INTO app.turns (
|
||||||
session_id, seq, speaker, stage, text, text_masked, actor_kind,
|
session_id, seq, speaker, stage, text, text_masked, actor_kind,
|
||||||
|
|
@ -559,6 +979,7 @@ async def append_turn(
|
||||||
$13, $14, $15, $16, $17::text[]
|
$13, $14, $15, $16, $17::text[]
|
||||||
)
|
)
|
||||||
ON CONFLICT (session_id, seq) DO NOTHING
|
ON CONFLICT (session_id, seq) DO NOTHING
|
||||||
|
RETURNING id
|
||||||
""",
|
""",
|
||||||
session_id,
|
session_id,
|
||||||
seq,
|
seq,
|
||||||
|
|
@ -578,6 +999,11 @@ async def append_turn(
|
||||||
turn.barge_in,
|
turn.barge_in,
|
||||||
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
|
list(turn.visible_to or DEFAULT_TURN_VISIBLE_TO),
|
||||||
)
|
)
|
||||||
|
if inserted_turn_id is None:
|
||||||
|
return False
|
||||||
|
turn.turn_id = str(inserted_turn_id)
|
||||||
|
# 원시 평가 row는 학습자 축어록이 아니라 evaluator 전용 데이터다.
|
||||||
|
await _persist_turn_evaluation(conn, turn.turn_id, turn.evaluation)
|
||||||
return True
|
return True
|
||||||
except Exception:
|
except Exception:
|
||||||
require_runtime_fallback_allowed("session turn append")
|
require_runtime_fallback_allowed("session turn append")
|
||||||
|
|
@ -703,7 +1129,7 @@ async def list_sessions(principal: Principal) -> tuple[list[InProcSession], bool
|
||||||
)
|
)
|
||||||
turn_rows = await conn.fetch(
|
turn_rows = await conn.fetch(
|
||||||
"""
|
"""
|
||||||
SELECT seq, speaker, stage, text, text_masked, created_at,
|
SELECT id, seq, speaker, stage, text, text_masked, created_at,
|
||||||
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
llm_provider, model, tokens_in, tokens_out, cost_usd,
|
||||||
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
||||||
FROM app.turns
|
FROM app.turns
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ class TurnRecord:
|
||||||
stage: str
|
stage: str
|
||||||
text: str # 원문(개발용; 실제 저장은 마스킹본)
|
text: str # 원문(개발용; 실제 저장은 마스킹본)
|
||||||
text_masked: str
|
text_masked: str
|
||||||
|
turn_id: str | None = None # DB app.turns.id. 런타임 폴백 턴은 None.
|
||||||
created_at: float = field(default_factory=time.time)
|
created_at: float = field(default_factory=time.time)
|
||||||
llm_provider: str | None = None
|
llm_provider: str | None = None
|
||||||
model: str | None = None
|
model: str | None = None
|
||||||
|
|
|
||||||
|
|
@ -29,13 +29,13 @@ def patched_settings(**values: Any):
|
||||||
setattr(settings, key, value)
|
setattr(settings, key, value)
|
||||||
|
|
||||||
|
|
||||||
def _request() -> Request:
|
def _request(headers: list[tuple[bytes, bytes]] | None = None) -> Request:
|
||||||
return Request(
|
return Request(
|
||||||
{
|
{
|
||||||
"type": "http",
|
"type": "http",
|
||||||
"method": "GET",
|
"method": "GET",
|
||||||
"path": "/auth/login",
|
"path": "/auth/login",
|
||||||
"headers": [(b"host", b"localhost:8000")],
|
"headers": headers or [(b"host", b"localhost:8000")],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -111,6 +111,64 @@ class AuthProviderScaffoldTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertTrue(providers["saml"].enabled)
|
self.assertTrue(providers["saml"].enabled)
|
||||||
self.assertEqual(providers["saml"].login_path, "/auth/login?provider=saml")
|
self.assertEqual(providers["saml"].login_path, "/auth/login?provider=saml")
|
||||||
|
|
||||||
|
async def test_auth_config_allows_dev_login_from_configured_tailnet_origin(self) -> None:
|
||||||
|
request = _request(
|
||||||
|
[
|
||||||
|
(b"host", b"127.0.0.1:8000"),
|
||||||
|
(b"origin", b"https://alpaca-home.taile93291.ts.net"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patched_settings(
|
||||||
|
environment="dev",
|
||||||
|
auth_dev_login_enabled=True,
|
||||||
|
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
|
||||||
|
):
|
||||||
|
config = await auth_routes.auth_config(request)
|
||||||
|
|
||||||
|
self.assertTrue(config.dev_login_enabled)
|
||||||
|
|
||||||
|
async def test_auth_config_allows_dev_login_from_configured_tailnet_forwarded_host(self) -> None:
|
||||||
|
request = _request(
|
||||||
|
[
|
||||||
|
(b"host", b"127.0.0.1:8000"),
|
||||||
|
(b"x-forwarded-host", b"alpaca-home.taile93291.ts.net"),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
with patched_settings(
|
||||||
|
environment="dev",
|
||||||
|
auth_dev_login_enabled=True,
|
||||||
|
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
|
||||||
|
):
|
||||||
|
config = await auth_routes.auth_config(request)
|
||||||
|
|
||||||
|
self.assertTrue(config.dev_login_enabled)
|
||||||
|
|
||||||
|
async def test_auth_config_allows_dev_login_config_probe_without_origin_when_extra_origin_is_set(self) -> None:
|
||||||
|
request = _request([(b"host", b"127.0.0.1:8000")])
|
||||||
|
|
||||||
|
with patched_settings(
|
||||||
|
environment="dev",
|
||||||
|
auth_dev_login_enabled=True,
|
||||||
|
auth_dev_login_extra_origins=["https://alpaca-home.taile93291.ts.net"],
|
||||||
|
):
|
||||||
|
config = await auth_routes.auth_config(request)
|
||||||
|
|
||||||
|
self.assertTrue(config.dev_login_enabled)
|
||||||
|
|
||||||
|
async def test_frontend_origin_map_routes_vnet_api_callbacks_to_vnet_frontend(self) -> None:
|
||||||
|
request = _request([(b"host", b"api-vnet.18ka.net")])
|
||||||
|
|
||||||
|
with patched_settings(
|
||||||
|
frontend_base_url="https://vignette.chanpaca.net",
|
||||||
|
frontend_origin_map={"api-vnet.18ka.net": "https://vnet.18ka.net"},
|
||||||
|
cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"],
|
||||||
|
):
|
||||||
|
origin = auth_routes._frontend_origin_for_request(request)
|
||||||
|
|
||||||
|
self.assertEqual(origin, "https://vnet.18ka.net")
|
||||||
|
|
||||||
async def test_saml_login_builds_redirect_authn_request_and_relay_state(self) -> None:
|
async def test_saml_login_builds_redirect_authn_request_and_relay_state(self) -> None:
|
||||||
with patched_settings(
|
with patched_settings(
|
||||||
auth_saml_enabled=True,
|
auth_saml_enabled=True,
|
||||||
|
|
|
||||||
247
apps/api/app/test_evaluation_persistence.py
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
"""Regression tests for turn-evaluation persistence mapping."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from .deps import Principal, Role
|
||||||
|
from . import session_persistence
|
||||||
|
from .routes import sessions
|
||||||
|
from .services import persona as persona_service
|
||||||
|
from .services import state_machine
|
||||||
|
from .store import InProcSession
|
||||||
|
|
||||||
|
|
||||||
|
class FakeEvaluationConn:
|
||||||
|
def __init__(self) -> None:
|
||||||
|
self.executed: list[tuple[str, tuple[object, ...]]] = []
|
||||||
|
self.fetchvals: list[tuple[str, tuple[object, ...]]] = []
|
||||||
|
|
||||||
|
async def execute(self, query: str, *args: object) -> str:
|
||||||
|
self.executed.append((query, args))
|
||||||
|
return "INSERT 0 1"
|
||||||
|
|
||||||
|
async def fetchval(self, query: str, *args: object) -> int:
|
||||||
|
self.fetchvals.append((query, args))
|
||||||
|
if "app.technique_label_def" in query:
|
||||||
|
return 101
|
||||||
|
if "app.client_state_def" in query:
|
||||||
|
return 202
|
||||||
|
raise AssertionError(f"unexpected fetchval query: {query}")
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluationPersistenceMappingTest(unittest.TestCase):
|
||||||
|
def test_feedback_rows_preserve_review_scalar_contract(self) -> None:
|
||||||
|
evaluation = {
|
||||||
|
"loop": "fast",
|
||||||
|
"turn_seq": 2,
|
||||||
|
"stage": "탐색",
|
||||||
|
"appropriateness": "pos",
|
||||||
|
"appropriateness_note": "정서를 먼저 반영했다.",
|
||||||
|
"rapport_signal": 0.75,
|
||||||
|
"theory_mode": "humanistic",
|
||||||
|
"techniques": [
|
||||||
|
{
|
||||||
|
"code": "empathy",
|
||||||
|
"label_ko": "공감",
|
||||||
|
"category": "relational",
|
||||||
|
"rationale": "감정을 명시적으로 반영했다.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"client_state_read": [
|
||||||
|
{
|
||||||
|
"code": "affect_contact",
|
||||||
|
"label_ko": "정서 접촉/표현",
|
||||||
|
"rationale": "내담자가 감정을 언급했다.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
rows = {
|
||||||
|
row["dimension"]: row
|
||||||
|
for row in session_persistence._evaluation_feedback_rows(evaluation)
|
||||||
|
}
|
||||||
|
|
||||||
|
self.assertEqual(rows["appropriateness"]["score"], 5.0)
|
||||||
|
self.assertEqual(rows["appropriateness"]["rationale"], "정서를 먼저 반영했다.")
|
||||||
|
self.assertEqual(rows["rapport_signal"]["score"], 0.75)
|
||||||
|
self.assertEqual(rows["theory_mode"]["rationale"], "humanistic")
|
||||||
|
self.assertEqual(rows["technique:empathy"]["rationale"], "감정을 명시적으로 반영했다.")
|
||||||
|
self.assertEqual(rows["client_state:affect_contact"]["rationale"], "내담자가 감정을 언급했다.")
|
||||||
|
|
||||||
|
def test_rebuild_turn_evaluation_restores_review_shape(self) -> None:
|
||||||
|
rebuilt = session_persistence._rebuild_turn_evaluations(
|
||||||
|
[("11111111-1111-1111-1111-111111111111", 2, "탐색")],
|
||||||
|
feedback_rows=[
|
||||||
|
{
|
||||||
|
"turn_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"dimension": "appropriateness",
|
||||||
|
"score": 1.0,
|
||||||
|
"rationale": "조언이 너무 빨랐다.",
|
||||||
|
"top1_score": None,
|
||||||
|
"loop": "fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"turn_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"dimension": "technique:empathy",
|
||||||
|
"score": None,
|
||||||
|
"rationale": "정서 반영이 포함됐다.",
|
||||||
|
"top1_score": None,
|
||||||
|
"loop": "fast",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"turn_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"dimension": "rapport_signal",
|
||||||
|
"score": -0.4,
|
||||||
|
"rationale": None,
|
||||||
|
"top1_score": None,
|
||||||
|
"loop": "fast",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
technique_rows=[
|
||||||
|
{
|
||||||
|
"turn_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"code": "empathy",
|
||||||
|
"label_ko": "공감",
|
||||||
|
"category": "relational",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
client_state_rows=[
|
||||||
|
{
|
||||||
|
"turn_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"code": "defensive",
|
||||||
|
"label_ko": "방어",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
comment_rows=[
|
||||||
|
{
|
||||||
|
"turn_id": "11111111-1111-1111-1111-111111111111",
|
||||||
|
"intent_deviation": {
|
||||||
|
"dimension": "pacing",
|
||||||
|
"expected": "감정 탐색",
|
||||||
|
"actual": "해결 조언",
|
||||||
|
"severity": "moderate",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
ev = rebuilt["11111111-1111-1111-1111-111111111111"]
|
||||||
|
self.assertEqual(ev["turn_seq"], 2)
|
||||||
|
self.assertEqual(ev["stage"], "탐색")
|
||||||
|
self.assertEqual(ev["appropriateness"], "warn")
|
||||||
|
self.assertEqual(ev["appropriateness_note"], "조언이 너무 빨랐다.")
|
||||||
|
self.assertEqual(ev["rapport_signal"], -0.4)
|
||||||
|
self.assertEqual(ev["techniques"][0]["rationale"], "정서 반영이 포함됐다.")
|
||||||
|
self.assertEqual(ev["client_state_read"][0]["label_ko"], "방어")
|
||||||
|
self.assertEqual(ev["intent_deviation"]["dimension"], "pacing")
|
||||||
|
|
||||||
|
def test_evaluation_rls_blocks_raw_learner_writes(self) -> None:
|
||||||
|
root = Path(__file__).resolve().parents[3]
|
||||||
|
sql = (root / "infra/db/init/04_audit_eval_rls.sql").read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("ALTER TABLE app.feedback_scores ENABLE ROW LEVEL SECURITY", sql)
|
||||||
|
self.assertIn("ALTER TABLE app.turn_technique ENABLE ROW LEVEL SECURITY", sql)
|
||||||
|
self.assertIn("ALTER TABLE app.turn_client_state ENABLE ROW LEVEL SECURITY", sql)
|
||||||
|
self.assertIn("ALTER TABLE app.supervisor_comment ENABLE ROW LEVEL SECURITY", sql)
|
||||||
|
self.assertIn("ALTER TABLE app.alternative_utterance ENABLE ROW LEVEL SECURITY", sql)
|
||||||
|
feedback_insert = sql.split("CREATE POLICY p_feedback_insert", 1)[1].split(");", 1)[0]
|
||||||
|
self.assertNotIn("learner_id = app.current_uid()", feedback_insert)
|
||||||
|
|
||||||
|
def test_append_turn_requires_inserted_turn_id(self) -> None:
|
||||||
|
source = Path(session_persistence.__file__).read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
self.assertIn("RETURNING id", source)
|
||||||
|
self.assertIn("if inserted_turn_id is None:", source)
|
||||||
|
|
||||||
|
|
||||||
|
class EvaluationPersistenceIOTest(unittest.IsolatedAsyncioTestCase):
|
||||||
|
async def test_persist_turn_evaluation_uses_evaluator_context_and_real_fast_tables(self) -> None:
|
||||||
|
conn = FakeEvaluationConn()
|
||||||
|
evaluation = {
|
||||||
|
"loop": "fast",
|
||||||
|
"turn_seq": 3,
|
||||||
|
"stage": "탐색",
|
||||||
|
"appropriateness": "warn",
|
||||||
|
"appropriateness_note": "해결 제안이 빨랐다.",
|
||||||
|
"techniques": [
|
||||||
|
{
|
||||||
|
"code": "empathy",
|
||||||
|
"label_ko": "공감",
|
||||||
|
"category": "relational",
|
||||||
|
"rationale": "정서 반영.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"client_state_read": [
|
||||||
|
{
|
||||||
|
"code": "defensive",
|
||||||
|
"label_ko": "방어",
|
||||||
|
"rationale": "짧은 회피 반응.",
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"intent_deviation": {
|
||||||
|
"dimension": "pacing",
|
||||||
|
"expected": "탐색",
|
||||||
|
"actual": "조언",
|
||||||
|
"severity": "minor",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
await session_persistence._persist_turn_evaluation(
|
||||||
|
conn,
|
||||||
|
"11111111-1111-1111-1111-111111111111",
|
||||||
|
evaluation,
|
||||||
|
)
|
||||||
|
|
||||||
|
executed_sql = "\n".join(query for query, _ in conn.executed)
|
||||||
|
self.assertIn("set_config('app.ai_context', '1', true)", executed_sql)
|
||||||
|
self.assertIn("set_config('app.current_ai_view', 'evaluator', true)", executed_sql)
|
||||||
|
self.assertIn("INSERT INTO app.feedback_scores", executed_sql)
|
||||||
|
self.assertIn("INSERT INTO app.turn_technique", executed_sql)
|
||||||
|
self.assertIn("INSERT INTO app.turn_client_state", executed_sql)
|
||||||
|
self.assertIn("INSERT INTO app.supervisor_comment", executed_sql)
|
||||||
|
self.assertNotIn("app.alternative_utterance", executed_sql)
|
||||||
|
|
||||||
|
async def test_route_loader_only_hydrates_when_requested(self) -> None:
|
||||||
|
principal = Principal(
|
||||||
|
user_id="00000000-0000-0000-0000-000000000101",
|
||||||
|
role=Role.LEARNER,
|
||||||
|
cohort_ids=[],
|
||||||
|
email="eval-map@hs.ac.kr",
|
||||||
|
display_name="Eval Map",
|
||||||
|
)
|
||||||
|
card = persona_service.P1
|
||||||
|
sess = InProcSession(
|
||||||
|
session_id="eval-map-session",
|
||||||
|
case_id="eval-map-case",
|
||||||
|
learner_id=principal.user_id,
|
||||||
|
persona_code=card.code,
|
||||||
|
theory_mode="humanistic",
|
||||||
|
persona=card,
|
||||||
|
state=state_machine.SessionState(
|
||||||
|
resistance=card.base_resistance(),
|
||||||
|
ideation_stage=card.ideation_baseline(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
calls: list[bool] = []
|
||||||
|
|
||||||
|
async def fake_load_session(*args, **kwargs):
|
||||||
|
calls.append(bool(kwargs.get("include_turn_evaluation")))
|
||||||
|
return sess
|
||||||
|
|
||||||
|
with patch.object(sessions.session_persistence, "load_session", fake_load_session):
|
||||||
|
await sessions._load_session_or_404(sess.session_id, principal)
|
||||||
|
await sessions._load_session_or_404(
|
||||||
|
sess.session_id,
|
||||||
|
principal,
|
||||||
|
allow_ended=True,
|
||||||
|
include_turn_evaluation=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(calls, [False, True])
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -222,11 +222,13 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
||||||
auth_dev_login_enabled=True,
|
auth_dev_login_enabled=True,
|
||||||
auto_seed_personas=True,
|
auto_seed_personas=True,
|
||||||
allow_seed_persona_fallback=True,
|
allow_seed_persona_fallback=True,
|
||||||
|
voice_poc_sample_tts_enabled=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception))
|
self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception))
|
||||||
self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception))
|
self.assertIn("AUTO_SEED_PERSONAS", str(caught.exception))
|
||||||
self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception))
|
self.assertIn("ALLOW_SEED_PERSONA_FALLBACK", str(caught.exception))
|
||||||
|
self.assertIn("VIGNETTE_VOICE_POC_SAMPLE_TTS", str(caught.exception))
|
||||||
|
|
||||||
def test_non_dev_rejects_missing_public_runtime_config(self) -> None:
|
def test_non_dev_rejects_missing_public_runtime_config(self) -> None:
|
||||||
with self.assertRaises(ValueError) as caught:
|
with self.assertRaises(ValueError) as caught:
|
||||||
|
|
@ -264,6 +266,43 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(cfg.environment, "staging")
|
self.assertEqual(cfg.environment, "staging")
|
||||||
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
|
self.assertEqual(cfg.cors_origins, ["https://vignette.chanpaca.net"])
|
||||||
|
|
||||||
|
def test_non_dev_accepts_vnet_frontend_origin_map(self) -> None:
|
||||||
|
cfg = Settings(
|
||||||
|
environment="prod",
|
||||||
|
auth_dev_login_enabled=False,
|
||||||
|
auto_seed_personas=False,
|
||||||
|
allow_seed_persona_fallback=False,
|
||||||
|
session_secret="prod-secret-change-me",
|
||||||
|
oauth_google_client_id="google-client-id",
|
||||||
|
oauth_google_client_secret="google-client-secret",
|
||||||
|
frontend_base_url="https://vignette.chanpaca.net",
|
||||||
|
frontend_origin_map={
|
||||||
|
"api-vignette.chanpaca.net": "https://vignette.chanpaca.net",
|
||||||
|
"api-vnet.18ka.net": "https://vnet.18ka.net",
|
||||||
|
},
|
||||||
|
cors_origins=["https://vignette.chanpaca.net", "https://vnet.18ka.net"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(cfg.frontend_origin_map["api-vnet.18ka.net"], "https://vnet.18ka.net")
|
||||||
|
self.assertIn("https://vnet.18ka.net", cfg.cors_origins)
|
||||||
|
|
||||||
|
def test_non_dev_rejects_local_frontend_origin_map(self) -> None:
|
||||||
|
with self.assertRaises(ValueError) as caught:
|
||||||
|
Settings(
|
||||||
|
environment="prod",
|
||||||
|
auth_dev_login_enabled=False,
|
||||||
|
auto_seed_personas=False,
|
||||||
|
allow_seed_persona_fallback=False,
|
||||||
|
session_secret="prod-secret-change-me",
|
||||||
|
oauth_google_client_id="google-client-id",
|
||||||
|
oauth_google_client_secret="google-client-secret",
|
||||||
|
frontend_base_url="https://vignette.chanpaca.net",
|
||||||
|
frontend_origin_map={"api.example.test": "http://localhost:9999"},
|
||||||
|
cors_origins=["https://vignette.chanpaca.net"],
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIn("FRONTEND_ORIGIN_MAP", str(caught.exception))
|
||||||
|
|
||||||
def test_non_dev_accepts_explicit_local_vite_cors_ports(self) -> None:
|
def test_non_dev_accepts_explicit_local_vite_cors_ports(self) -> None:
|
||||||
cfg = Settings(
|
cfg = Settings(
|
||||||
environment="prod",
|
environment="prod",
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,8 @@
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
from tempfile import TemporaryDirectory
|
||||||
|
|
||||||
from .services.voice import (
|
from .services.voice import (
|
||||||
DEFAULT_OPENAI_VOICE,
|
DEFAULT_OPENAI_VOICE,
|
||||||
|
|
@ -173,6 +175,49 @@ class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase):
|
||||||
self.assertEqual(payload["instructions"], "Keep the tone grounded.")
|
self.assertEqual(payload["instructions"], "Keep the tone grounded.")
|
||||||
self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"])
|
self.assertEqual([chunk.audio for chunk in chunks], [b"\x80\x80", b"\xff\x00"])
|
||||||
|
|
||||||
|
async def test_dev_p1_sample_tts_streams_local_mp3_without_openai_key(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
sample_dir = Path(tmp)
|
||||||
|
default_audio = b"default-mp3"
|
||||||
|
anxious_audio = (b"anxious-mp3-" * 500)
|
||||||
|
(sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(default_audio)
|
||||||
|
(sample_dir / "p1_seoyeon_03_anxious_guarded.mp3").write_bytes(anxious_audio)
|
||||||
|
service = VoiceService(
|
||||||
|
api_key="",
|
||||||
|
poc_sample_tts_enabled=True,
|
||||||
|
environment="dev",
|
||||||
|
poc_sample_tts_dir=sample_dir,
|
||||||
|
)
|
||||||
|
voice = VoicePreset(preset="soft-young-fem", openai_voice="coral")
|
||||||
|
|
||||||
|
chunks = [
|
||||||
|
chunk
|
||||||
|
async for chunk in service.synthesize_stream(
|
||||||
|
"엄마한테 말하지 않는 거죠? 진짜 불안해요.",
|
||||||
|
voice,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
self.assertFalse(service.is_available())
|
||||||
|
self.assertTrue(service.poc_sample_tts_available())
|
||||||
|
self.assertEqual(service.tts_provider(), "p1-sample-poc")
|
||||||
|
self.assertEqual(b"".join(chunk.audio for chunk in chunks), anxious_audio)
|
||||||
|
|
||||||
|
async def test_p1_sample_tts_is_disabled_outside_dev(self) -> None:
|
||||||
|
with TemporaryDirectory() as tmp:
|
||||||
|
sample_dir = Path(tmp)
|
||||||
|
(sample_dir / "p1_seoyeon_01_depressed_slow.mp3").write_bytes(b"default-mp3")
|
||||||
|
service = VoiceService(
|
||||||
|
api_key="",
|
||||||
|
poc_sample_tts_enabled=True,
|
||||||
|
environment="prod",
|
||||||
|
poc_sample_tts_dir=sample_dir,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertFalse(service.is_available())
|
||||||
|
self.assertFalse(service.poc_sample_tts_available())
|
||||||
|
self.assertEqual(service.tts_provider(), "disabled-non-dev")
|
||||||
|
|
||||||
|
|
||||||
class EndOfTurnDecisionTest(unittest.TestCase):
|
class EndOfTurnDecisionTest(unittest.TestCase):
|
||||||
def test_end_of_turn_requires_silence_threshold(self) -> None:
|
def test_end_of_turn_requires_silence_threshold(self) -> None:
|
||||||
|
|
|
||||||
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-left.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/blush-right.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/body.png
Normal file
|
After Width: | Height: | Size: 39 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-left.png
Normal file
|
After Width: | Height: | Size: 7 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/brow-right.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/clavicle.png
Normal file
|
After Width: | Height: | Size: 8.9 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-left.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/ear-right.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-left.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/eye-right.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 7.6 KiB |
|
After Width: | Height: | Size: 6 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 7 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 7.2 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 11 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/face-base.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/face.png
Normal file
|
After Width: | Height: | Size: 81 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 253 KiB |
|
After Width: | Height: | Size: 254 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-back.png
Normal file
|
After Width: | Height: | Size: 562 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-bangs.png
Normal file
|
After Width: | Height: | Size: 122 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/hair-front.png
Normal file
|
After Width: | Height: | Size: 282 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-left.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/iris-right.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-left.png
Normal file
|
After Width: | Height: | Size: 7.7 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/lash-right.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 6.1 KiB |
|
After Width: | Height: | Size: 7.1 KiB |
|
After Width: | Height: | Size: 16 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-open.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-sad.png
Normal file
|
After Width: | Height: | Size: 4.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-tired.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth-warm.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/mouth.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/neck.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/nose.png
Normal file
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-inner.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit-outer.png
Normal file
|
After Width: | Height: | Size: 135 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/outfit.png
Normal file
|
After Width: | Height: | Size: 207 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-left.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/pupil-right.png
Normal file
|
After Width: | Height: | Size: 4.9 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-psb/parts/ribbon.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-left.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/brow-right.png
Normal file
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 4.6 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-back.png
Normal file
|
After Width: | Height: | Size: 288 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-front.png
Normal file
|
After Width: | Height: | Size: 159 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-left.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/hair-right.png
Normal file
|
After Width: | Height: | Size: 121 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/head-faceless.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 4.2 KiB |
|
After Width: | Height: | Size: 4.1 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-left.png
Normal file
|
After Width: | Height: | Size: 7.4 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/iris-right.png
Normal file
|
After Width: | Height: | Size: 7.1 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-left.png
Normal file
|
After Width: | Height: | Size: 8.4 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/lash-right.png
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-neutral.png
Normal file
|
After Width: | Height: | Size: 6.4 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-open.png
Normal file
|
After Width: | Height: | Size: 9.3 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-sad.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/mouth-warm.png
Normal file
|
After Width: | Height: | Size: 4.4 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/neck-fill.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/nose.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
apps/web/public/avatar/seoyeon-live2d-v3/parts/pupil-left.png
Normal file
|
After Width: | Height: | Size: 4.7 KiB |