현재 작업 상태 저장
|
|
@ -12,6 +12,8 @@ ENGINE_MODE=claude_cli
|
|||
ANTHROPIC_API_KEY=
|
||||
OPENAI_API_KEY=
|
||||
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.
|
||||
SESSION_SECRET=dev-insecure-change-me
|
||||
|
|
@ -22,12 +24,14 @@ AUTH_ALLOWED_EMAIL_DOMAINS=["hs.ac.kr","twentyoz.kr"]
|
|||
AUTH_TEACHER_EMAILS=[]
|
||||
AUTH_ADMIN_EMAILS=[]
|
||||
AUTH_DEV_LOGIN_ENABLED=true
|
||||
AUTH_DEV_LOGIN_EXTRA_ORIGINS=[]
|
||||
DEFAULT_AFFILIATION=
|
||||
AUTO_SEED_PERSONAS=false
|
||||
ALLOW_SEED_PERSONA_FALLBACK=false
|
||||
EVALUATOR_GOLDEN_FEWSHOT_ENABLED=false
|
||||
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.
|
||||
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):
|
||||
model_config = SettingsConfigDict(
|
||||
env_file=".env",
|
||||
|
|
@ -84,6 +88,14 @@ class Settings(BaseSettings):
|
|||
default="https://api.openai.com/v1",
|
||||
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, 토큰 서버 보관) ────────
|
||||
session_secret: str = Field(
|
||||
|
|
@ -143,15 +155,27 @@ class Settings(BaseSettings):
|
|||
default="http://localhost:5173",
|
||||
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_origins: list[str] = Field(
|
||||
default=[
|
||||
"https://vignette.chanpaca.net",
|
||||
"https://vnet.18ka.net",
|
||||
"https://vignette-b1q.pages.dev",
|
||||
],
|
||||
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.
|
||||
# Production should use approved rows from app.persona_card only.
|
||||
|
|
@ -181,6 +205,8 @@ class Settings(BaseSettings):
|
|||
forbidden.append("AUTO_SEED_PERSONAS")
|
||||
if self.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():
|
||||
forbidden.append("OAUTH_GOOGLE_CLIENT_ID")
|
||||
if not self.oauth_google_client_secret.strip():
|
||||
|
|
@ -189,11 +215,13 @@ class Settings(BaseSettings):
|
|||
forbidden.append("SESSION_SECRET")
|
||||
if _is_local_url(self.frontend_base_url):
|
||||
forbidden.append("FRONTEND_BASE_URL")
|
||||
if any(
|
||||
_is_local_url(origin) and not _is_allowed_local_dev_cors_origin(origin)
|
||||
for origin in self.cors_origins
|
||||
):
|
||||
if any(_is_local_or_forbidden_non_dev_origin(origin) for origin in self.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:
|
||||
joined = ", ".join(forbidden)
|
||||
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"}
|
||||
|
||||
|
||||
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]:
|
||||
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)
|
||||
if origin and origin not in origins:
|
||||
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")
|
||||
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
||||
hostname = host.rsplit(":", 1)[0].lower() if host else ""
|
||||
if hostname == "api-vignette.chanpaca.net":
|
||||
return "https://vignette.chanpaca.net"
|
||||
if mapped_origin := _frontend_origin_map().get(hostname):
|
||||
return mapped_origin
|
||||
if hostname in {"localhost", "127.0.0.1", "::1"}:
|
||||
return fallback
|
||||
|
||||
|
|
@ -348,15 +379,24 @@ def _dev_login_available(request: Request) -> bool:
|
|||
if settings.environment != "dev" or not settings.auth_dev_login_enabled:
|
||||
return False
|
||||
|
||||
if _dev_login_extra_origins():
|
||||
return True
|
||||
|
||||
saw_browser_origin = False
|
||||
for header_name in ("origin", "referer"):
|
||||
origin = _url_origin(request.headers.get(header_name))
|
||||
if origin and not _is_local_origin(origin):
|
||||
if origin:
|
||||
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")
|
||||
host = (forwarded_host or request.headers.get("host") or "").split(",", 1)[0].strip()
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -381,8 +381,14 @@ async def _load_session_or_404(
|
|||
principal: Principal,
|
||||
*,
|
||||
allow_ended: bool = False,
|
||||
include_turn_evaluation: bool = False,
|
||||
) -> 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:
|
||||
store.put(sess)
|
||||
elif runtime_fallback_allowed():
|
||||
|
|
@ -874,7 +880,11 @@ async def get_session_detail(
|
|||
) -> SessionDetailResponse:
|
||||
"""Return a learner-owned session with transcript for resume/history."""
|
||||
_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))
|
||||
|
||||
|
||||
|
|
@ -951,7 +961,12 @@ async def get_session_review(
|
|||
) -> SessionReviewResponse:
|
||||
"""Return a learner-safe review built only from the stored session transcript."""
|
||||
_ensure_learner(principal)
|
||||
sess = await _load_session_or_404(session_id, principal, allow_ended=True)
|
||||
sess = await _load_session_or_404(
|
||||
session_id,
|
||||
principal,
|
||||
allow_ended=True,
|
||||
include_turn_evaluation=True,
|
||||
)
|
||||
visible_turns = _learner_visible_turns(sess)
|
||||
hidden_turns = len(visible_turns) != len(sess.turns)
|
||||
|
||||
|
|
|
|||
|
|
@ -49,11 +49,13 @@ _MAX_AUDIO_BYTES = 10 * 1024 * 1024
|
|||
async def voice_health() -> JSONResponse:
|
||||
"""Return voice service readiness."""
|
||||
available = voice_service.is_available()
|
||||
tts_provider = voice_service.tts_provider()
|
||||
body = {
|
||||
"status": "ok" if available else "degraded",
|
||||
"available": available,
|
||||
"stt_model": voice_svc.STT_MODEL,
|
||||
"tts_model": voice_svc.TTS_MODEL,
|
||||
"tts_provider": tts_provider,
|
||||
"reason": None if available else "OPENAI_API_KEY is not configured",
|
||||
}
|
||||
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,
|
||||
"voice": voice_preset.openai_voice,
|
||||
"preset": voice_preset.preset,
|
||||
"tts_provider": voice_service.tts_provider(),
|
||||
"state": "idle",
|
||||
**bind_meta,
|
||||
},
|
||||
|
|
@ -374,7 +377,12 @@ async def _run_turn_and_speak(
|
|||
# TTS speaking state comes before chunk metadata and binary audio.
|
||||
await _safe_send_json(
|
||||
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:
|
||||
n = 0
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ from __future__ import annotations
|
|||
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import AsyncIterator, Optional
|
||||
|
||||
import httpx
|
||||
|
|
@ -49,6 +50,32 @@ TTS_RESPONSE_FORMAT = "mp3"
|
|||
# End-of-turn readiness default for cascaded STT providers.
|
||||
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,
|
||||
# nova, onyx, sage, shimmer, verse. 페르소나 톤별로 골라 매핑한다.
|
||||
_OPENAI_VOICES = {
|
||||
|
|
@ -240,9 +267,32 @@ def assess_end_of_turn(
|
|||
class VoiceService:
|
||||
"""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._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
|
||||
|
||||
# ── 수명주기 ──────────────────────────────────────────
|
||||
|
|
@ -264,6 +314,35 @@ class VoiceService:
|
|||
"""음성 기능 가용 여부(키 설정됨). 라우트가 핸드셰이크에서 검사."""
|
||||
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
|
||||
def _http(self) -> httpx.AsyncClient:
|
||||
if not self._api_key:
|
||||
|
|
@ -341,6 +420,10 @@ class VoiceService:
|
|||
text = speakable_text(text)
|
||||
if not text:
|
||||
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(
|
||||
text,
|
||||
voice,
|
||||
|
|
@ -375,6 +458,25 @@ class VoiceService:
|
|||
except httpx.HTTPError as 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]:
|
||||
"""tts-1 폴백(비스트림 POST → 전체 바이트를 청크로 분할)."""
|
||||
try:
|
||||
|
|
|
|||
|
|
@ -18,6 +18,11 @@ from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
|
|||
|
||||
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
|
||||
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
|
||||
_APPROPRIATENESS_SCORE = {
|
||||
"warn": 1.0,
|
||||
"neutral": 3.0,
|
||||
"pos": 5.0,
|
||||
}
|
||||
|
||||
|
||||
_JOINED_CARD_COLUMNS = (
|
||||
|
|
@ -71,6 +76,248 @@ def _stage(stage: object) -> str:
|
|||
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(
|
||||
conn: Any,
|
||||
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()
|
||||
return TurnRecord(
|
||||
turn_seq=int(row["seq"]),
|
||||
|
|
@ -121,6 +368,7 @@ def _turn_from_row(row) -> TurnRecord:
|
|||
stage=row["stage"],
|
||||
text=row["text"] or row["text_masked"] 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,
|
||||
llm_provider=_row_value(row, "llm_provider"),
|
||||
model=_row_value(row, "model"),
|
||||
|
|
@ -131,10 +379,179 @@ def _turn_from_row(row) -> TurnRecord:
|
|||
silence_ms=_row_value(row, "silence_ms"),
|
||||
speech_rate=_row_value(row, "speech_rate"),
|
||||
barge_in=_row_value(row, "barge_in"),
|
||||
evaluation=evaluation,
|
||||
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:
|
||||
"""Create runtime review/evaluation storage when the DB role allows it."""
|
||||
try:
|
||||
|
|
@ -441,6 +858,7 @@ async def load_session(
|
|||
principal: Principal,
|
||||
*,
|
||||
allow_ended: bool = False,
|
||||
include_turn_evaluation: bool = False,
|
||||
) -> InProcSession | None:
|
||||
try:
|
||||
get_pool()
|
||||
|
|
@ -496,7 +914,7 @@ async def load_session(
|
|||
)
|
||||
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,
|
||||
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
||||
FROM app.turns
|
||||
|
|
@ -518,6 +936,8 @@ async def load_session(
|
|||
"learner_id": sess.learner_id,
|
||||
},
|
||||
)
|
||||
if sess is not None and include_turn_evaluation:
|
||||
await _hydrate_session_turn_evaluations(sess)
|
||||
return sess
|
||||
except Exception:
|
||||
require_runtime_fallback_allowed("session load")
|
||||
|
|
@ -546,7 +966,7 @@ async def append_turn(
|
|||
)
|
||||
or 1
|
||||
)
|
||||
await conn.execute(
|
||||
inserted_turn_id = await conn.fetchval(
|
||||
"""
|
||||
INSERT INTO app.turns (
|
||||
session_id, seq, speaker, stage, text, text_masked, actor_kind,
|
||||
|
|
@ -559,6 +979,7 @@ async def append_turn(
|
|||
$13, $14, $15, $16, $17::text[]
|
||||
)
|
||||
ON CONFLICT (session_id, seq) DO NOTHING
|
||||
RETURNING id
|
||||
""",
|
||||
session_id,
|
||||
seq,
|
||||
|
|
@ -578,6 +999,11 @@ async def append_turn(
|
|||
turn.barge_in,
|
||||
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
|
||||
except Exception:
|
||||
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(
|
||||
"""
|
||||
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,
|
||||
audio_ref, silence_ms, speech_rate, barge_in, visible_to
|
||||
FROM app.turns
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@ class TurnRecord:
|
|||
stage: str
|
||||
text: str # 원문(개발용; 실제 저장은 마스킹본)
|
||||
text_masked: str
|
||||
turn_id: str | None = None # DB app.turns.id. 런타임 폴백 턴은 None.
|
||||
created_at: float = field(default_factory=time.time)
|
||||
llm_provider: str | None = None
|
||||
model: str | None = None
|
||||
|
|
|
|||
|
|
@ -29,13 +29,13 @@ def patched_settings(**values: Any):
|
|||
setattr(settings, key, value)
|
||||
|
||||
|
||||
def _request() -> Request:
|
||||
def _request(headers: list[tuple[bytes, bytes]] | None = None) -> Request:
|
||||
return Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "GET",
|
||||
"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.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:
|
||||
with patched_settings(
|
||||
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,
|
||||
auto_seed_personas=True,
|
||||
allow_seed_persona_fallback=True,
|
||||
voice_poc_sample_tts_enabled=True,
|
||||
)
|
||||
|
||||
self.assertIn("AUTH_DEV_LOGIN_ENABLED", str(caught.exception))
|
||||
self.assertIn("AUTO_SEED_PERSONAS", 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:
|
||||
with self.assertRaises(ValueError) as caught:
|
||||
|
|
@ -264,6 +266,43 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(cfg.environment, "staging")
|
||||
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:
|
||||
cfg = Settings(
|
||||
environment="prod",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
from .services.voice import (
|
||||
DEFAULT_OPENAI_VOICE,
|
||||
|
|
@ -173,6 +175,49 @@ class VoiceServiceStreamTest(unittest.IsolatedAsyncioTestCase):
|
|||
self.assertEqual(payload["instructions"], "Keep the tone grounded.")
|
||||
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):
|
||||
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 |