현재 작업 상태 저장

This commit is contained in:
Yun Chan 2026-06-27 11:20:24 +09:00
parent 07cc67761e
commit 6bd91b0d5e
674 changed files with 8726 additions and 298 deletions

View file

@ -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
View 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) 참조.

View file

@ -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}")

View file

@ -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:
saw_browser_origin = True
if not _is_dev_login_allowed_origin(origin):
return False 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)

View file

@ -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)

View file

@ -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

View file

@ -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:

View file

@ -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,6 +936,8 @@ async def load_session(
"learner_id": sess.learner_id, "learner_id": sess.learner_id,
}, },
) )
if sess is not None and include_turn_evaluation:
await _hydrate_session_turn_evaluations(sess)
return sess return sess
except Exception: except Exception:
require_runtime_fallback_allowed("session load") require_runtime_fallback_allowed("session load")
@ -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

View file

@ -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

View file

@ -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,

View 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()

View file

@ -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",

View file

@ -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:

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 253 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 254 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 562 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 282 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 207 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 288 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 159 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.7 KiB

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