음성 재생과 운영 배포 정리

This commit is contained in:
Yun Chan 2026-06-28 12:18:20 +09:00
parent 8ed185ce6c
commit ac7db95542
1020 changed files with 46863 additions and 2175 deletions

17
.dockerignore Normal file
View file

@ -0,0 +1,17 @@
# API 이미지는 저장소 루트 컨텍스트에서 빌드하지만 필요한 입력만 보낸다.
*
!apps/
!apps/api/
!apps/api/**
!data/
!data/**
apps/api/.venv/
apps/api/uploads/
apps/api/**/__pycache__/
**/.env
**/.env.*
!**/.env.example
data/raw/
**/축어록*
**/*사례*.docx

1
.gitignore vendored
View file

@ -33,6 +33,7 @@ venv/
*.egg-info/
.pytest_cache/
.ruff_cache/
apps/api/uploads/
# === Docker / 런타임 ===
*.pid

View file

@ -152,3 +152,13 @@ bash ~/.codex/imagegen-headless/codex_imagegen.sh \
### 4.4 진행 중인 아바타 작업 핸드오프
서연(P1) 아바타 작업은 **별도 세션에서 진행**. 현재 상태·남은 작업(Image #2=짧은 보브 기준 재생성 등)은
[`docs/ops/handoff-avatar-seoyeon-2026-06-27.md`](./docs/ops/handoff-avatar-seoyeon-2026-06-27.md) 참조.
### 4.5 페르소나별 Live2D 파츠 생성 규칙
P4~P7 등 다른 페르소나 파츠를 imagegen으로 만들 때는 `docs/avatar-art/personas/README.md`를 먼저 읽고 따른다.
- **시트 금지**: 여러 파츠를 한 이미지에 모으지 않는다. 파츠 하나당 생성 파일 하나다.
- **입력 참조는 원본 파츠 crop**: 900×1125 전체 캔버스가 아니라 원본 `alphaBox` 기준 tight crop을 imagegen 참조로 준다.
- **최종 산출물은 900×1125 투명 PNG**: 생성 결과에서 실제 파츠만 crop/resize한 뒤 원본 `alphaBox` 위치에 강제 paste한다.
- **검증 필수**: 최종 파츠 bbox drift는 원본 `alphaBox` 대비 2px 이내여야 한다. 검증 없는 DONE 표기 금지.
- **시각 QA 필수**: `visual_qa.py`로 앱 합성 순서의 neutral/sad preview와 contact sheet를 만들고 직접 본다. 눈 위치, 외계인 같은 비대칭, 초록 chroma-key 헤이즈, 엣지 블리딩, 여성/남성 페르소나 불일치를 잡기 전에는 앱에 연결하지 않는다.
- **남성/엣지 보정**: P5/P7처럼 남성 짧은 머리 페르소나는 긴 머리 슬롯을 그대로 채우면 안 된다. `apply_visual_overrides.py`로 긴 뒷머리·긴 사이드 슬롯을 비우고 리본형 outfit을 제거하며, 저알파 chroma-key 초록 엣지도 정리한 뒤 다시 visual QA를 통과시킨다.
- **기본 러너**: `python -X utf8 docs/avatar-art/personas/run_part_imagegen.py ...`.

View file

@ -9,7 +9,8 @@
오케스트레이터(`services/orchestrator.py`, `prepare_turn`/`run_turn_generate`, `eval_hook`/`log_hook` 주입형) ·
저항엔진(state_machine openness) · 마스킹 게이트(PII, Presidio + 정규식) ·
음성 캐스케이드(STT/TTS, `voice.py`) · 회기 리뷰(`evaluator.py` deep-loop + `make_eval_hook` fast-loop) ·
평가 KPI(SUS·자기효능감·κ/ICC·환각률) · 재귀학습(`ds.*` 스키마) · 데이터/SSO 거버넌스(`saml.py`, auth allowlist).
평가 KPI(SUS·자기효능감·κ/ICC·환각률) · 재귀학습(`ds.*` 스키마) · 데이터/SSO 거버넌스(`saml.py`, auth allowlist) ·
회기 리뷰 공유 URL(공개 토큰 + Open Graph/요약 카드) · SEO/GEO 정적 메타(`robots.txt`, `sitemap.xml`, `llms.txt`).
AI 턴 생성 엔진은 별도 서비스인 **engine_gateway**(포트 9099)가 담당하며
`ENGINE_MODE`로 백엔드를 고른다(`claude_cli` / `claude_api` / `openai` / `solar`).
@ -24,7 +25,7 @@ AI 턴 생성 엔진은 별도 서비스인 **engine_gateway**(포트 9099)가
| `apps/api/` | FastAPI/Python 백엔드. 오케스트레이터·페르소나·저항엔진·마스킹·음성·평가·인증. 엔진 게이트웨이(`apps/api/engine_gateway/`) 포함 |
| `apps/web/` | React 19 + Vite 프론트엔드(3역할: 관리자/교수자/학습자). Playwright E2E |
| `docs/` | 설계·운영 문서. `docs/dev_dashboard.html`이 SSOT(단일 진실 공급원) |
| `infra/` | Docker Compose 스택(`db`=pgvector pg16, `api`, `web`, `rag`, `proxy`=Caddy) 및 `.env.example` |
| `infra/` | Docker Compose 스택(`db`=pgvector pg16, `api`, `web`, `proxy`=Caddy) 및 `.env.example` |
| `scripts/` | 운영 스크립트(PowerShell/Python): 공개 런타임 기동·감시, 엔진 게이트웨이 프로브, Postgres RLS 감사 등 |
---
@ -75,6 +76,9 @@ curl -X POST http://localhost:5173/api/auth/dev-login `
- 요청 바디: `email`(필수), `role`(`learner` | `teacher` | `admin`, 기본 `learner`), `display_name`(선택).
- 성공 시 `__Host-vignette_sid` 세션 쿠키가 발급된다.
- 로그인 후 신규 사용자 또는 온보딩 미완료 사용자는 `/onboarding`에서 닉네임, 자기소개,
선택 아바타 이미지, 이름, 소속, 학과, 학년/직위, 전화번호, 주소/수령지와 약관·개인정보
동의를 완료해야 역할 홈과 학습 회기 시작이 열린다.
### 2-4. (선택) AI 턴 생성용 엔진 게이트웨이
@ -91,8 +95,8 @@ uvicorn engine_gateway.gateway:app --host 0.0.0.0 --port 9099
```powershell
# 백엔드
cd apps\api; python -m pytest app/ -q # 현재 113 pass
python -m pytest engine_gateway/ -q # 약 7 pass
cd apps\api; python -m pytest app/ -q # 현재 145 pass
python -m pytest engine_gateway/ -q # 현재 9 pass
# 프론트엔드
cd apps\web; npm run typecheck # tsc -b
npm run build # tsc -b && vite build
@ -104,10 +108,12 @@ npm run e2e # Playwright (web + api + DB 스
```powershell
cd infra
Copy-Item .env.example .env # 실제 시크릿은 .env 에만
docker compose up -d # db(pgvector pg16) + api + web + rag + proxy(Caddy)
docker compose up -d # db(pgvector pg16) + api + web + proxy(Caddy)
```
Docker Desktop이 필요하다.
Docker Desktop이 필요하다. RAG 모델 의존성까지 API 이미지에 넣을 때만 `INSTALL_RAG=true``infra/.env`에 둔다.
다른 환경에 심기 전에는 저장소 루트에서 `python scripts\check-deploy-preflight.py --env-file infra\.env`를 먼저 실행해
고정 의존성, 라이브 코칭 `data/kb` source pack, 운영 env를 확인한다.
---

View file

@ -1,13 +1,23 @@
# Vignette API (FastAPI) — 이식 가능한 슬림 이미지
# Vignette API (FastAPI) - 이식 가능한 슬림 이미지
# 빌드 컨텍스트는 저장소 루트여야 한다: docker build -f apps/api/Dockerfile .
FROM python:3.11-slim AS base
ENV PYTHONUNBUFFERED=1 PYTHONDONTWRITEBYTECODE=1 PIP_NO_CACHE_DIR=1
WORKDIR /app
# 의존성 레이어 분리(캐시 효율)
COPY requirements.txt ./
RUN pip install -r requirements.txt
ARG INSTALL_RAG=false
COPY . .
# 의존성 레이어 분리(캐시 효율). RAG 의존성은 torch/model 포함이라 명시 opt-in.
COPY apps/api/requirements.txt apps/api/requirements-rag.txt ./apps/api/
RUN pip install -r apps/api/requirements.txt \
&& if [ "$INSTALL_RAG" = "true" ]; then pip install -r apps/api/requirements-rag.txt; fi
# 코드가 저장소 루트(/app)를 기준으로 data/personas 등을 찾으므로 루트 형태를 유지한다.
COPY apps/api ./apps/api
COPY data ./data
RUN mkdir -p /app/uploads
ENV PYTHONPATH=/app/apps/api
WORKDIR /app/apps/api
EXPOSE 8000
# 엔진/DB/음성 엔드포인트는 전부 env 로 주입(이미지에 굽지 않음 = 이식성)

View file

@ -1,11 +0,0 @@
# Vignette RAG 사이드카 — BGE-M3 임베딩 + BGE-reranker-v2-m3
# CPU로도 동작(20명 규모 충분), GPU 호스트면 nvidia runtime 으로 가속.
FROM python:3.11-slim
ENV PYTHONUNBUFFERED=1 HF_HOME=/models
WORKDIR /app
COPY requirements-rag.txt ./
RUN pip install --no-cache-dir -r requirements-rag.txt
COPY rag/ ./rag/
EXPOSE 8080
# 모델은 첫 기동 시 /models 볼륨에 캐시 → 이식 시 볼륨만 옮기면 재다운로드 불필요
CMD ["uvicorn", "rag.server:app", "--host", "0.0.0.0", "--port", "8080"]

View file

@ -0,0 +1,8 @@
"""Shared auth contract types."""
from __future__ import annotations
from typing import Literal
AccountStatus = Literal["pending", "approved", "suspended"]
RoleName = Literal["learner", "teacher", "admin"]

View file

@ -139,6 +139,18 @@ class Settings(BaseSettings):
default=[],
validation_alias="AUTH_ADMIN_EMAILS",
)
auth_super_admin_emails: list[str] = Field(
default=["yunchan@twentyoz.kr", "hoonjungkoo@hs.ac.kr"],
validation_alias="AUTH_SUPER_ADMIN_EMAILS",
)
auth_approved_emails: list[str] = Field(
default=[],
validation_alias="AUTH_APPROVED_EMAILS",
)
auth_new_user_default_status: Literal["pending", "approved"] = Field(
default="pending",
validation_alias="AUTH_NEW_USER_DEFAULT_STATUS",
)
auth_email_cohort_map: dict[str, str] = Field(
default_factory=dict,
validation_alias="AUTH_EMAIL_COHORT_MAP",
@ -182,6 +194,10 @@ class Settings(BaseSettings):
},
validation_alias="FRONTEND_ORIGIN_MAP",
)
user_upload_dir: str = Field(
default="uploads",
validation_alias="USER_UPLOAD_DIR",
)
# ── CORS (정적 프론트 + SSE 분리경로) ────────────────
cors_origins: list[str] = Field(

View file

@ -0,0 +1 @@
"""Cross-runtime contracts for replaceable service boundaries."""

View file

@ -0,0 +1,82 @@
"""Engine gateway HTTP and SSE contract.
Keep this module adapter-neutral. The Python API client, the current Python
gateway, and a future Node.js gateway must preserve these shapes.
"""
from __future__ import annotations
import json
from typing import Any, Literal, Optional
from pydantic import BaseModel, Field
AIRole = Literal["client", "counselor", "evaluator"]
EngineMessageRole = Literal["system", "user", "assistant"]
EngineGatewaySseEvent = Literal["token", "done", "error"]
ENGINE_GATEWAY_SSE_TOKEN: EngineGatewaySseEvent = "token"
ENGINE_GATEWAY_SSE_DONE: EngineGatewaySseEvent = "done"
ENGINE_GATEWAY_SSE_ERROR: EngineGatewaySseEvent = "error"
ENGINE_GATEWAY_SSE_EVENTS: tuple[EngineGatewaySseEvent, ...] = (
ENGINE_GATEWAY_SSE_TOKEN,
ENGINE_GATEWAY_SSE_DONE,
ENGINE_GATEWAY_SSE_ERROR,
)
class EngineMessage(BaseModel):
role: EngineMessageRole
content: str
cache: bool = False
class GenerateRequest(BaseModel):
ai_role: AIRole = "client"
messages: list[EngineMessage]
model: Optional[str] = None
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None
session_id: Optional[str] = None
metadata: dict[str, Any] = Field(default_factory=dict)
class StreamRequest(GenerateRequest):
"""SSE stream request for live client AI turns."""
class GenerateResponse(BaseModel):
text: str
model: str
provider: str
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
inference_geo: Optional[str] = None
structured: Optional[dict[str, Any]] = None
class StreamTokenEvent(BaseModel):
text: str
class StreamDoneEvent(BaseModel):
provider: str
model: str
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
turns: int = 0
class StreamErrorEvent(BaseModel):
detail: str
def sse_frame(event: EngineGatewaySseEvent, payload: BaseModel | dict[str, Any]) -> str:
if isinstance(payload, BaseModel):
body = payload.model_dump()
else:
body = payload
return f"event: {event}\ndata: {json.dumps(body, ensure_ascii=False)}\n\n"

View file

@ -148,7 +148,9 @@ async def healthcheck() -> bool:
to_regclass('app.app_user') IS NOT NULL AS has_user,
to_regclass('app.auth_session') IS NOT NULL AS has_auth_session,
to_regclass('app.user_preferences') IS NOT NULL AS has_preferences,
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config
to_regclass('app.admin_engine_config') IS NOT NULL AS has_engine_config,
to_regclass('app.admin_health_event') IS NOT NULL AS has_admin_health_event,
to_regclass('app.support_ticket') IS NOT NULL AS has_support_ticket
"""
)
return bool(
@ -157,6 +159,8 @@ async def healthcheck() -> bool:
and row["has_auth_session"]
and row["has_preferences"]
and row["has_engine_config"]
and row["has_admin_health_event"]
and row["has_support_ticket"]
)
except Exception:
return False

View file

@ -6,8 +6,9 @@ from enum import Enum
from typing import Annotated, AsyncIterator, Optional
import asyncpg
from fastapi import Cookie, Depends, HTTPException, status
from fastapi import Cookie, Depends, HTTPException, Request, status
from .auth_types import AccountStatus
from .auth_sessions import get_session
from .config import Settings, get_settings
from .db import acquire
@ -32,17 +33,48 @@ class Principal:
self,
user_id: str,
role: Role,
admin_access: bool = False,
super_admin: bool = False,
account_status: AccountStatus = "approved",
cohort_ids: Optional[list[str]] = None,
email: str = "",
display_name: str = "",
consent_at: float | None = None,
profile_completed_at: float | None = None,
) -> None:
self.user_id = user_id
self.role = role
self.admin_access = admin_access
self.super_admin = super_admin
self.account_status = account_status
self.cohort_ids = cohort_ids or []
self.email = email
self.display_name = display_name
self.consent_at = consent_at
self.profile_completed_at = profile_completed_at
def can_access_role(self, role: Role) -> bool:
if self.role == role:
return True
if self.super_admin:
return True
if role == Role.ADMIN:
return self.admin_access
return False
def with_role(self, role: Role) -> "Principal":
return Principal(
user_id=self.user_id,
role=role,
admin_access=self.admin_access,
super_admin=self.super_admin,
account_status=self.account_status,
cohort_ids=list(self.cohort_ids),
email=self.email,
display_name=self.display_name,
consent_at=self.consent_at,
profile_completed_at=self.profile_completed_at,
)
def get_settings_dep() -> Settings:
@ -50,6 +82,7 @@ def get_settings_dep() -> Settings:
async def get_current_principal(
request: Request,
session_cookie: Annotated[Optional[str], Cookie(alias="__Host-vignette_sid")] = None,
dev_session_cookie: Annotated[Optional[str], Cookie(alias="vignette_sid")] = None,
) -> Principal:
@ -70,13 +103,23 @@ async def get_current_principal(
detail="invalid session role",
) from exc
if session.account_status != "approved" and request.url.path != "/auth/me":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"account_{session.account_status}",
)
return Principal(
user_id=session.user_id,
role=role,
admin_access=session.admin_access,
super_admin=session.super_admin,
account_status=session.account_status,
cohort_ids=session.cohort_ids,
email=session.email,
display_name=session.display_name,
consent_at=session.consent_at,
profile_completed_at=session.profile_completed_at,
)
@ -86,10 +129,29 @@ def require_role(*allowed: Role):
async def _checker(
principal: Annotated[Principal, Depends(get_current_principal)],
) -> Principal:
if principal.role not in allowed:
if principal.role in allowed:
return principal
if principal.super_admin:
effective = Role.ADMIN if Role.ADMIN in allowed else allowed[0]
return principal.with_role(effective)
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role {principal.role.value} not permitted",
)
return _checker
def require_admin_access():
"""기본 역할과 별개로 관리자 권한을 가진 사용자만 통과시킨다."""
async def _checker(
principal: Annotated[Principal, Depends(get_current_principal)],
) -> Principal:
if not principal.admin_access:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"role {principal.role.value} not permitted",
detail="admin access required",
)
return principal

View file

@ -16,50 +16,18 @@ text 는 *PII 마스킹 후(text_masked)* 만 보낸다 (R7/F-03, 마스킹은
from __future__ import annotations
import asyncio
from typing import Any, AsyncIterator, Literal, Optional
from typing import Any, AsyncIterator, Optional
import httpx
from pydantic import BaseModel, Field
from .config import settings
AIRole = Literal["client", "counselor", "evaluator"]
# ── 요청/응답 계약 모델 ─────────────────────────────────
class EngineMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
# 프롬프트 캐싱 힌트 (설계서 §1.2 L0~L2 cache_control). 게이트웨이가 해석.
cache: bool = False
class GenerateRequest(BaseModel):
"""단발 생성 요청 (평가 AI deep-loop, 회기종료 압축 등)."""
ai_role: AIRole
messages: list[EngineMessage]
model: Optional[str] = None # 명시 시 게이트웨이 override
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None # Structured Outputs (CCD 비노출 강제)
session_id: Optional[str] = None # 비용 텔레메트리 귀속
metadata: dict[str, Any] = Field(default_factory=dict)
class GenerateResponse(BaseModel):
text: str
model: str
provider: str
tokens_in: int = 0
tokens_out: int = 0
cost_usd: float = 0.0
inference_geo: Optional[str] = None # 'kr'|'us' (데이터 주권 audit)
structured: Optional[dict[str, Any]] = None
class StreamRequest(GenerateRequest):
"""SSE 스트림 요청 (내담자 AI 실시간 응답)."""
from .contracts.engine_gateway import (
AIRole,
EngineMessage,
GenerateRequest,
GenerateResponse,
StreamRequest,
)
class EngineError(RuntimeError):

View file

@ -8,9 +8,11 @@ Dockerfile CMD: uvicorn app.main:app --host 0.0.0.0 --port 8000
from __future__ import annotations
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from . import __version__
from .auth_sessions import ensure_runtime_tables
@ -25,6 +27,7 @@ from .routes import eval as eval_routes
from .routes import kb as kb_routes
from .routes import personas as persona_routes
from .routes import sessions as session_routes
from .routes import share as share_routes
from .routes import teacher as teacher_routes
from .routes import users as user_routes
from .routes import voice as voice_routes
@ -82,10 +85,17 @@ app.add_middleware(
# TODO: Presidio PII 마스킹 미들웨어 (외부 LLM 경로 진입 전 하드 게이트, R7/F-03)
_upload_root = Path(settings.user_upload_dir)
if not _upload_root.is_absolute():
_upload_root = Path.cwd() / _upload_root
_upload_root.mkdir(parents=True, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=str(_upload_root)), name="uploads")
app.include_router(auth_routes.router)
app.include_router(admin_routes.router)
app.include_router(persona_routes.router)
app.include_router(session_routes.router)
app.include_router(share_routes.router)
app.include_router(teacher_routes.router)
app.include_router(user_routes.router)
# Features 트랙 스텁 라우터(evaluator/voice/rag 가 채움). 등록만 — import 가능 보장.

40
apps/api/app/paths.py Normal file
View file

@ -0,0 +1,40 @@
"""Filesystem path helpers for source-checkout and container runtimes."""
from __future__ import annotations
import os
from functools import lru_cache
from pathlib import Path
@lru_cache(maxsize=1)
def repo_root() -> Path:
"""Return the Vignette repository root.
The API is run both from a source checkout and from Docker, where the code
lives under /app/apps/api. Keep that layout knowledge here instead of
repeating fragile ``Path(__file__).parents[...]`` offsets across modules.
"""
override = os.getenv("VIGNETTE_REPO_ROOT", "").strip()
if override:
return Path(override).expanduser().resolve()
current = Path(__file__).resolve()
for candidate in current.parents:
if (
(candidate / "apps" / "api" / "app").is_dir()
and (candidate / "data").exists()
):
return candidate
for candidate in current.parents:
if (
(candidate / "apps" / "api").is_dir()
and (candidate / "README.md").exists()
):
return candidate
raise RuntimeError("Vignette repository root not found; set VIGNETTE_REPO_ROOT")
def repo_path(*parts: str) -> Path:
"""Build an absolute path under the repository root."""
return repo_root().joinpath(*parts)

View file

@ -15,17 +15,20 @@ from typing import Any, Iterable, Literal, cast
from .config import settings
from .db import acquire, get_pool
from .paths import repo_path
from .services.persona import PersonaCard, SEED_PERSONAS, get_seed_persona
from .services.voice import resolve_voice
SEED_VERSION = 1
REPO_PERSONA_DIR = Path(__file__).resolve().parents[3] / "data" / "personas"
REPO_PERSONA_DIR = repo_path("data", "personas")
PersonaStatus = Literal["draft", "review", "approved", "archived"]
_CARD_COLUMNS = """
persona_id, code, version, status, display_name, difficulty, theory_target,
demographics, presenting, history, big5, resistance, speech_style,
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic
affect_baseline, ccd, dsm5_dimensional, source_provenance, is_synthetic,
triggers
"""
_REVIEW_COLUMNS = """
@ -47,6 +50,13 @@ class CatalogPersona:
degraded: bool = False
@dataclass(frozen=True, slots=True)
class PersonaVoiceMap:
provider: str
voice_id: str
base_params: dict[str, Any]
@dataclass(frozen=True, slots=True)
class PersonaReviewItem:
persona_id: str
@ -89,6 +99,7 @@ def _card_from_payload(payload: dict[str, Any]) -> PersonaCard:
dsm5_dimensional=_json_dict(payload.get("dsm5_dimensional")),
source_provenance=str(payload.get("source_provenance") or ""),
is_synthetic=bool(payload.get("is_synthetic", True)),
triggers=_json_dict(payload.get("triggers")),
)
@ -162,6 +173,7 @@ def card_from_row(row: Any) -> PersonaCard:
dsm5_dimensional=_json_dict(row["dsm5_dimensional"]),
source_provenance=str(row["source_provenance"] or ""),
is_synthetic=bool(row["is_synthetic"]),
triggers=_json_dict(row["triggers"]),
)
@ -198,6 +210,14 @@ def persona_draft_record_from_row(row: Any) -> PersonaDraftRecord:
)
def persona_voice_map_from_row(row: Any) -> PersonaVoiceMap:
return PersonaVoiceMap(
provider=str(row["provider"]),
voice_id=str(row["voice_id"]),
base_params=_json_dict(row["base_params"]),
)
def seed_fallback_persona(code: str) -> CatalogPersona | None:
normalized = code.strip().upper()
card = get_seed_persona(normalized)
@ -228,45 +248,34 @@ def seed_fallback_personas() -> list[CatalogPersona]:
async def materialize_seed_personas() -> int:
"""Upsert built-in seed personas as approved DB catalog rows."""
"""Materialize built-in seed personas as editable DB catalog rows.
Existing rows belong to the authoring database. Startup seeding must not
overwrite faculty edits or resurrect archived personas.
"""
get_pool()
count = 0
async with acquire(role="admin") as conn:
for card in built_in_personas():
persona_id = seed_persona_id(card.code)
await conn.execute(
"""
INSERT INTO app.persona_card (
persona_id, code, version, status, display_name, difficulty,
theory_target, demographics, presenting, history, big5,
resistance, speech_style, affect_baseline, ccd,
dsm5_dimensional, source_provenance, is_synthetic,
dsm5_dimensional, source_provenance, is_synthetic, triggers,
approved_at
)
VALUES (
$1::uuid, $2, $3, 'approved', $4, $5,
$6::text[], $7::jsonb, $8::jsonb, $9::jsonb, $10::jsonb,
$11::jsonb, $12::jsonb, $13::jsonb, $14::jsonb,
$15::jsonb, $16, $17, now()
$15::jsonb, $16, $17, $18::jsonb, now()
)
ON CONFLICT (code, version) DO UPDATE SET
status = 'approved',
display_name = EXCLUDED.display_name,
difficulty = EXCLUDED.difficulty,
theory_target = EXCLUDED.theory_target,
demographics = EXCLUDED.demographics,
presenting = EXCLUDED.presenting,
history = EXCLUDED.history,
big5 = EXCLUDED.big5,
resistance = EXCLUDED.resistance,
speech_style = EXCLUDED.speech_style,
affect_baseline = EXCLUDED.affect_baseline,
ccd = EXCLUDED.ccd,
dsm5_dimensional = EXCLUDED.dsm5_dimensional,
source_provenance = EXCLUDED.source_provenance,
is_synthetic = EXCLUDED.is_synthetic,
approved_at = COALESCE(persona_card.approved_at, now())
ON CONFLICT (code, version) DO NOTHING
""",
seed_persona_id(card.code),
persona_id,
card.code,
SEED_VERSION,
card.display_name,
@ -283,6 +292,25 @@ async def materialize_seed_personas() -> int:
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
)
voice = resolve_voice(persona_code=card.code)
await conn.execute(
"""
INSERT INTO app.persona_voice_map (
persona_id, version, voice_id, provider, base_params, prosody_map
)
VALUES ($1::uuid, $2, $3, 'openai', $4::jsonb, '{}'::jsonb)
ON CONFLICT (persona_id, version) DO NOTHING
""",
persona_id,
SEED_VERSION,
voice.openai_voice,
{
"preset": voice.preset,
"openai_voice": voice.openai_voice,
"rate": voice.rate,
},
)
count += 1
return count
@ -326,6 +354,45 @@ async def get_approved_persona(code: str) -> CatalogPersona | None:
return catalog_persona_from_row(row) if row is not None else None
async def get_persona_voice_map(
*,
persona_id: str | None,
version: int | None,
) -> PersonaVoiceMap | None:
if not persona_id or version is None:
return None
get_pool()
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
"""
SELECT provider, voice_id, base_params
FROM app.persona_voice_map
WHERE persona_id = $1::uuid
AND version = $2
""",
persona_id,
version,
)
return persona_voice_map_from_row(row) if row is not None else None
async def get_session_voice_map(session_id: str) -> PersonaVoiceMap | None:
get_pool()
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
"""
SELECT pvm.provider, pvm.voice_id, pvm.base_params
FROM app.sessions AS s
JOIN app.persona_voice_map AS pvm
ON pvm.persona_id = s.persona_id
AND pvm.version = s.persona_version
WHERE s.id = $1::uuid
""",
session_id,
)
return persona_voice_map_from_row(row) if row is not None else None
async def list_persona_review_queue(
*,
role: str,
@ -409,13 +476,14 @@ async def create_persona_draft(
persona_id, code, version, status, display_name, difficulty,
theory_target, demographics, presenting, history, big5,
resistance, speech_style, affect_baseline, ccd,
dsm5_dimensional, source_provenance, is_synthetic, created_by
dsm5_dimensional, source_provenance, is_synthetic, triggers,
created_by
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6,
$7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
$12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
$16::jsonb, $17, $18, $19::uuid
$16::jsonb, $17, $18, $19::jsonb, $20::uuid
)
RETURNING {_REVIEW_COLUMNS}
""",
@ -437,6 +505,7 @@ async def create_persona_draft(
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
author_id,
)
await conn.execute(
@ -459,6 +528,113 @@ async def create_persona_draft(
return persona_review_item_from_row(row)
async def create_persona_revision_from_existing(
*,
persona_id: str,
author_id: str,
role: str,
submit_for_review: bool = False,
) -> PersonaDraftRecord | None:
"""Clone an approved persona into a new editable draft version."""
if role not in {"teacher", "admin"}:
raise ValueError("persona revision creation requires teacher or admin role")
next_status = "review" if submit_for_review else "draft"
get_pool()
async with acquire(role=role, user_id=author_id) as conn:
source = await conn.fetchrow(
f"""
SELECT {_CARD_COLUMNS}, created_at, approved_at
FROM app.persona_card
WHERE persona_id = $1::uuid
AND status = 'approved'
ORDER BY version DESC
LIMIT 1
""",
persona_id,
)
if source is None:
return None
card = card_from_row(source)
existing = await conn.fetchrow(
f"""
SELECT {_CARD_COLUMNS}, created_at, approved_at
FROM app.persona_card
WHERE upper(code) = upper($1)
AND status IN ('draft', 'review')
ORDER BY version DESC
LIMIT 1
""",
card.code,
)
if existing is not None:
return persona_draft_record_from_row(existing)
version = await conn.fetchval(
"""
SELECT COALESCE(MAX(version), 0) + 1
FROM app.persona_card
WHERE upper(code) = upper($1)
""",
card.code,
)
row = await conn.fetchrow(
f"""
INSERT INTO app.persona_card (
persona_id, code, version, status, display_name, difficulty,
theory_target, demographics, presenting, history, big5,
resistance, speech_style, affect_baseline, ccd,
dsm5_dimensional, source_provenance, is_synthetic, triggers,
created_by
)
VALUES (
$1::uuid, $2, $3, $4, $5, $6,
$7::text[], $8::jsonb, $9::jsonb, $10::jsonb, $11::jsonb,
$12::jsonb, $13::jsonb, $14::jsonb, $15::jsonb,
$16::jsonb, $17, $18, $19::jsonb, $20::uuid
)
RETURNING {_CARD_COLUMNS}, created_at, approved_at
""",
persona_id,
card.code,
int(version or 1),
next_status,
card.display_name,
card.difficulty,
card.theory_target,
card.demographics,
card.presenting,
card.history,
card.big5,
card.resistance,
card.speech_style,
card.affect_baseline,
card.ccd,
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
author_id,
)
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
author_id,
"persona_revision_create",
"persona_card",
persona_id,
{
"next_status": next_status,
"code": card.code,
"version": int(row["version"]),
},
)
return persona_draft_record_from_row(row)
async def update_persona_draft(
*,
persona_id: str,
@ -493,6 +669,7 @@ async def update_persona_draft(
dsm5_dimensional = $15::jsonb,
source_provenance = $16,
is_synthetic = $17,
triggers = $18::jsonb,
approved_by = NULL,
approved_at = NULL
WHERE persona_id = $1::uuid
@ -516,6 +693,7 @@ async def update_persona_draft(
card.dsm5_dimensional,
card.source_provenance,
card.is_synthetic,
card.triggers,
)
if row is None:
return None
@ -594,6 +772,73 @@ async def update_persona_review_status(
return persona_review_item_from_row(row)
async def archive_persona_family(
*,
persona_id: str,
archiver_id: str,
role: str,
) -> PersonaReviewItem | None:
"""Archive every non-archived version for the persona code.
This is the delete operation exposed to faculty. It preserves historical
session foreign keys while removing the persona from the start catalog.
"""
if role not in {"teacher", "admin"}:
raise ValueError("persona archive requires teacher or admin role")
get_pool()
async with acquire(role=role, user_id=archiver_id) as conn:
row = await conn.fetchrow(
f"""
WITH target AS (
SELECT code
FROM app.persona_card
WHERE persona_id = $1::uuid
AND status <> 'archived'
LIMIT 1
),
archived AS (
UPDATE app.persona_card AS card
SET
status = 'archived',
approved_by = NULL,
approved_at = NULL
FROM target
WHERE upper(card.code) = upper(target.code)
AND card.status <> 'archived'
RETURNING {_REVIEW_COLUMNS}
)
SELECT {_REVIEW_COLUMNS}
FROM archived
WHERE persona_id = $1::uuid
ORDER BY version DESC
LIMIT 1
""",
persona_id,
)
if row is None:
return None
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
archiver_id,
"persona_archive",
"persona_card",
persona_id,
{
"next_status": "archived",
"code": str(row["code"]).upper(),
"version": int(row["version"]),
"scope": "code_family",
},
)
return persona_review_item_from_row(row)
async def list_catalog_personas() -> list[CatalogPersona]:
try:
return await list_approved_personas()
@ -619,20 +864,26 @@ __all__ = [
"PersonaReviewAction",
"PersonaStatus",
"SEED_VERSION",
"archive_persona_family",
"built_in_personas",
"card_from_row",
"catalog_persona_from_row",
"create_persona_draft",
"create_persona_revision_from_existing",
"get_approved_persona",
"get_catalog_persona",
"get_persona_draft_record",
"get_persona_voice_map",
"get_session_voice_map",
"list_approved_personas",
"list_catalog_personas",
"list_persona_review_queue",
"load_file_personas",
"materialize_seed_personas",
"persona_draft_record_from_row",
"persona_voice_map_from_row",
"persona_review_item_from_row",
"PersonaVoiceMap",
"seed_fallback_persona",
"seed_fallback_personas",
"seed_persona_id",

View file

@ -152,6 +152,8 @@ class AdminSupportTicketResponse(BaseModel):
created_at: float
updated_at: float
resolved_at: float | None = None
event_count: int = 0
last_event_at: float | None = None
class AdminTicketSummary(BaseModel):
@ -545,31 +547,67 @@ async def _uptime_from_database(window_hours: int) -> AdminUptimeResponse:
async def _tickets_from_database(
*,
ticket_status: TicketStatus | None,
category: TicketCategory | None = None,
priority: TicketPriority | None = None,
assigned_group: str | None = None,
source_path: str | None = None,
stale_only: bool = False,
search: str = "",
window_days: int,
) -> AdminTicketsResponse:
assigned_group_filter = (assigned_group or "").strip()
source_path_filter = (source_path or "").strip()
search_filter = search.strip().lower()
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
SELECT
id,
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
status,
subject,
body,
source_path,
assigned_group,
resolution_note,
created_at,
updated_at,
resolved_at
FROM app.support_ticket
t.id,
t.reporter_id,
t.reporter_email,
t.reporter_name,
t.reporter_role,
t.category,
t.priority,
t.status,
t.subject,
t.body,
t.source_path,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
WHERE action = 'support_ticket_update'
AND target_kind = 'support_ticket'
AND target_id = t.id::text
) AS ev ON TRUE
WHERE ($1::text IS NULL OR status = $1)
AND created_at >= now() - ($2::int * interval '1 day')
AND ($2::text IS NULL OR category = $2)
AND ($3::text IS NULL OR priority = $3)
AND ($4::text = '' OR assigned_group = $4)
AND ($5::text = '' OR source_path = $5)
AND (
NOT $6::bool
OR (
status NOT IN ('resolved', 'closed')
AND updated_at < now() - interval '1 day'
)
)
AND (
$7::text = ''
OR lower(subject) LIKE '%' || $7 || '%'
OR lower(body) LIKE '%' || $7 || '%'
OR lower(source_path) LIKE '%' || $7 || '%'
OR lower(reporter_email) LIKE '%' || $7 || '%'
)
AND created_at >= now() - ($8::int * interval '1 day')
ORDER BY
CASE
WHEN status = 'open' THEN 0
@ -588,6 +626,12 @@ async def _tickets_from_database(
LIMIT 120
""",
ticket_status,
category,
priority,
assigned_group_filter,
source_path_filter,
stale_only,
search_filter,
window_days,
)
tickets = [_ticket_from_row(row) for row in rows]
@ -758,6 +802,13 @@ def _row_ts(value: object) -> float | None:
return None
def _row_value(row, key: str, default=None):
try:
return row[key]
except (IndexError, KeyError, TypeError):
return default
def _engine_config_from_row(row) -> AdminEngineConfigResponse:
return AdminEngineConfigResponse(
engine_mode=_normalize_engine_mode(row["engine_mode"]),
@ -804,6 +855,8 @@ def _ticket_from_row(row) -> AdminSupportTicketResponse:
created_at=_row_ts(row["created_at"]) or 0.0,
updated_at=_row_ts(row["updated_at"]) or 0.0,
resolved_at=_row_ts(row["resolved_at"]),
event_count=int(_row_value(row, "event_count", 0) or 0),
last_event_at=_row_ts(_row_value(row, "last_event_at")),
)
@ -872,6 +925,50 @@ def _unavailable_tickets() -> AdminTicketsResponse:
)
def _ticket_change_detail(old_row, new_row) -> dict[str, object]:
changed_fields: list[str] = []
detail: dict[str, object] = {"changed_fields": changed_fields}
for field in ("status", "priority", "assigned_group"):
before = _row_value(old_row, field, "")
after = _row_value(new_row, field, "")
if before != after:
changed_fields.append(field)
detail[field] = {"from": before, "to": after}
old_note = (_row_value(old_row, "resolution_note", "") or "").strip()
new_note = (_row_value(new_row, "resolution_note", "") or "").strip()
if old_note != new_note:
changed_fields.append("resolution_note")
detail["resolution_note"] = {
"from_present": bool(old_note),
"to_present": bool(new_note),
}
detail["category"] = _row_value(new_row, "category", "")
detail["source_path"] = _row_value(new_row, "source_path", "")
return detail
async def _record_ticket_update_audit(
conn,
*,
principal: Principal,
ticket_id: str,
detail: dict[str, object],
) -> None:
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
principal.user_id,
"support_ticket_update",
"support_ticket",
ticket_id,
detail,
)
async def _current_engine_config() -> AdminEngineConfigResponse:
if _ENGINE_CONFIG is not None:
return _ENGINE_CONFIG
@ -1090,11 +1187,26 @@ async def admin_uptime(
async def list_tickets(
principal: AdminPrincipal,
ticket_status: Annotated[TicketStatus | None, Query(alias="status")] = None,
category: TicketCategory | None = None,
priority: TicketPriority | None = None,
assigned_group: Annotated[str | None, Query(max_length=120)] = None,
source_path: Annotated[str | None, Query(max_length=300)] = None,
stale_only: bool = False,
search: Annotated[str, Query(max_length=120)] = "",
window_days: Annotated[int, Query(ge=1, le=365)] = 30,
) -> AdminTicketsResponse:
"""Return user-submitted operational tickets without synthetic fallback rows."""
try:
return await _tickets_from_database(ticket_status=ticket_status, window_days=window_days)
return await _tickets_from_database(
ticket_status=ticket_status,
category=category,
priority=priority,
assigned_group=assigned_group,
source_path=source_path,
stale_only=stale_only,
search=search,
window_days=window_days,
)
except Exception:
return _unavailable_tickets()
@ -1108,6 +1220,23 @@ async def patch_ticket(
"""Update ticket triage state for administrators."""
try:
async with acquire(role="admin", user_id=principal.user_id) as conn:
old_row = await conn.fetchrow(
"""
SELECT
id,
category,
priority,
status,
source_path,
assigned_group,
resolution_note
FROM app.support_ticket
WHERE id = $1::uuid
""",
ticket_id,
)
if old_row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
row = await conn.fetchrow(
"""
UPDATE app.support_ticket SET
@ -1149,13 +1278,56 @@ async def patch_ticket(
body.assigned_group.strip() if body.assigned_group is not None else None,
body.resolution_note.strip() if body.resolution_note is not None else None,
)
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
detail = _ticket_change_detail(old_row, row)
if detail["changed_fields"]:
await _record_ticket_update_audit(
conn,
principal=principal,
ticket_id=ticket_id,
detail=detail,
)
row = await conn.fetchrow(
"""
SELECT
t.id,
t.reporter_id,
t.reporter_email,
t.reporter_name,
t.reporter_role,
t.category,
t.priority,
t.status,
t.subject,
t.body,
t.source_path,
t.assigned_group,
t.resolution_note,
t.created_at,
t.updated_at,
t.resolved_at,
COALESCE(ev.event_count, 0) AS event_count,
ev.last_event_at
FROM app.support_ticket AS t
LEFT JOIN LATERAL (
SELECT count(*)::int AS event_count, max(created_at) AS last_event_at
FROM audit.audit_log
WHERE action = 'support_ticket_update'
AND target_kind = 'support_ticket'
AND target_id = t.id::text
) AS ev ON TRUE
WHERE t.id = $1::uuid
""",
ticket_id,
)
except Exception as exc:
if isinstance(exc, HTTPException):
raise
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="ticket persistence unavailable",
) from exc
if row is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="ticket not found")
return _ticket_from_row(row)

View file

@ -22,7 +22,7 @@ from pydantic import BaseModel, Field
from ..db import acquire, get_pool
from ..deps import AIView, Principal, Role, require_role
from ..services import rag
from ..services import live_coach, rag
router = APIRouter(prefix="/kb", tags=["kb"])
@ -104,6 +104,24 @@ class IndexResponse(BaseModel):
degraded: bool = False
class LiveCoachSourcePackSyncItem(BaseModel):
source_id: str
doc_id: Optional[int]
chunks_indexed: int
skipped_unchanged: bool
embedded: bool
degraded: bool = False
class LiveCoachSourcePackSyncResponse(BaseModel):
sources_upserted: int
chunks_indexed: int
skipped_unchanged: int
embedded: bool
degraded: bool = False
items: list[LiveCoachSourcePackSyncItem] = Field(default_factory=list)
# ── 헬퍼: rag.NotConfigured → 503 ───────────────────────
def _to_chunk_out(c: rag.RetrievedChunk) -> ChunkOut:
return ChunkOut(
@ -222,6 +240,7 @@ async def eval_grounding(body: KBSearchRequest) -> KBSearchResponse:
query=body.query,
k=body.k,
kinds=body.kb_kind,
source_ids=body.source_id,
rerank=body.rerank,
)
try:
@ -319,3 +338,80 @@ async def index_document(
embedded=result.embedded,
degraded=result.degraded,
)
@router.post(
"/live-coach/source-packs/sync",
response_model=LiveCoachSourcePackSyncResponse,
status_code=status.HTTP_202_ACCEPTED,
)
async def sync_live_coach_source_packs(
principal: Annotated[Principal, Depends(require_role(Role.ADMIN))],
) -> LiveCoachSourcePackSyncResponse:
"""허가된 라이브 코칭 source pack을 kb.source/kb.chunk RAG 색인에 적재한다.
로컬 `data/kb/live_coaching_*.json` UI 즉시 코칭의 기본 근거이고, 경로는 같은 자료를
evaluator RAG 검색에도 올린다. source row를 먼저 upsert한 content_hash 기반 증분 색인을
수행한다. 임베딩 모델 미가용 BM25-only degraded 색인으로 이어진다.
"""
source_rows = live_coach.build_rag_source_rows()
index_payloads = live_coach.build_rag_index_payloads()
if not source_rows or not index_payloads:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="live coach source packs are empty",
)
items: list[LiveCoachSourcePackSyncItem] = []
source_row_by_id = {row["source_id"]: row for row in source_rows}
try:
async with acquire() as conn:
for row in source_rows:
await conn.execute(
"""
INSERT INTO kb.source
(source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok)
VALUES ($1, $2, $3, $4, $5, $6, $7)
ON CONFLICT (source_id) DO UPDATE SET
title = EXCLUDED.title,
kb_kind = EXCLUDED.kb_kind,
license_class = EXCLUDED.license_class,
origin_path = EXCLUDED.origin_path,
citation = EXCLUDED.citation,
external_llm_ok = EXCLUDED.external_llm_ok
""",
row["source_id"],
row["title"],
row["kb_kind"],
row["license_class"],
row["origin_path"],
row["citation"],
row["external_llm_ok"],
)
for payload in index_payloads:
if payload["source_id"] not in source_row_by_id:
continue
result = await rag.index_document(conn, rag.IndexRequest(**payload))
items.append(
LiveCoachSourcePackSyncItem(
source_id=payload["source_id"],
doc_id=result.doc_id,
chunks_indexed=result.chunks_indexed,
skipped_unchanged=result.skipped_unchanged,
embedded=result.embedded,
degraded=result.degraded,
)
)
except rag.NotConfigured as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"RAG not configured: {exc}") from exc
except RuntimeError as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
return LiveCoachSourcePackSyncResponse(
sources_upserted=len(source_rows),
chunks_indexed=sum(item.chunks_indexed for item in items),
skipped_unchanged=sum(1 for item in items if item.skipped_unchanged),
embedded=all(item.embedded for item in items) if items else False,
degraded=any(item.degraded for item in items),
items=items,
)

View file

@ -2,33 +2,58 @@
from __future__ import annotations
import json
import hashlib
import re
import uuid
from typing import Annotated, Any, Literal
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, Field
from ..db import acquire
from ..deps import CurrentPrincipal, Principal, Role, require_role
from ..deps import AIView
from ..persona_repository import (
CatalogPersona,
PersonaDraftRecord,
PersonaReviewAction,
PersonaReviewItem,
archive_persona_family,
create_persona_draft,
create_persona_revision_from_existing,
get_persona_draft_record,
list_catalog_personas,
list_persona_review_queue,
update_persona_draft,
update_persona_review_status,
)
from ..engine_client import EngineError, EngineMessage, GenerateRequest, engine_client
from ..services import rag
from ..services.guardrail import mask_pii
from ..services.persona import PersonaCard
router = APIRouter(prefix="/personas", tags=["personas"])
TeacherOrAdmin = Annotated[Principal, Depends(require_role(Role.TEACHER, Role.ADMIN))]
JSON_OBJECT_FIELD = {"additionalProperties": True}
PersonaSourceKind = Literal["client_record", "textbook_guide", "mixed_notes"]
PERSONA_SOURCE_KB_KIND: dict[str, str] = {
"client_record": "diagnostic",
"textbook_guide": "theory",
"mixed_notes": "ko_context",
}
PERSONA_SOURCE_CITATION: dict[str, str] = {
"client_record": "교수자 첨부 PII 마스킹 파생본 — 실사례 원문은 저장하지 않음",
"textbook_guide": "교수자 첨부 교재/가이드 환언·발췌 근거 — 저작권 검수 필요",
"mixed_notes": "교수자 첨부 혼합 메모 PII 마스킹 파생본",
}
class PersonaSummary(BaseModel):
persona_id: str | None = None
code: str
version: int | None = None
status: Literal["approved"] = "approved"
display_name: str
difficulty: str
theory_target: list[str]
@ -57,6 +82,10 @@ class PersonaReviewDecisionRequest(BaseModel):
action: PersonaReviewAction
class PersonaRevisionRequest(BaseModel):
submit_for_review: bool = False
class PersonaDraftPayload(BaseModel):
code: str = Field(min_length=1, max_length=24)
display_name: str = Field(min_length=1, max_length=80)
@ -71,11 +100,66 @@ class PersonaDraftPayload(BaseModel):
affect_baseline: dict[str, float] = Field(default_factory=dict)
ccd: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
dsm5_dimensional: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
triggers: dict[str, Any] = Field(default_factory=dict, json_schema_extra=JSON_OBJECT_FIELD)
source_provenance: str = Field(default="", max_length=240)
is_synthetic: bool = True
submit_for_review: bool = False
class PersonaSourceDocumentRequest(BaseModel):
filename: str = Field(min_length=1, max_length=240)
source_kind: PersonaSourceKind = "mixed_notes"
text: str = Field(min_length=20, max_length=120000)
title: str | None = Field(default=None, max_length=160)
source_note: str = Field(default="", max_length=800)
class PersonaSourceDocumentResponse(BaseModel):
source_id: str
doc_id: int | None
doc_uri: str
title: str
source_kind: PersonaSourceKind
kb_kind: str
license_class: Literal["A", "B", "C", "D"] = "B"
external_llm_ok: bool = True
content_hash: str
chunk_count: int
chunks_indexed: int
embedded: bool
degraded: bool = False
pii_entities_masked: list[str] = Field(default_factory=list)
class PersonaGenerationEvidence(BaseModel):
chunk_id: int
source_id: str
score: float
kb_kind: str
heading_path: str | None = None
excerpt: str
class PersonaDraftGenerateRequest(BaseModel):
source_text: str | None = Field(default=None, min_length=20, max_length=30000)
source_ids: list[str] = Field(default_factory=list, max_length=12)
source_kind: PersonaSourceKind = "mixed_notes"
code_hint: str | None = Field(default=None, max_length=24)
display_name_hint: str | None = Field(default=None, max_length=80)
difficulty: Literal["easy", "moderate", "hard"] = "moderate"
theory_target: list[str] = Field(default_factory=lambda: ["humanistic"])
generation_goal: str = Field(default="", max_length=800)
class PersonaDraftGenerateResponse(BaseModel):
draft: PersonaDraftPayload
source_summary: str = ""
warnings: list[str] = Field(default_factory=list)
pii_entities_masked: list[str] = Field(default_factory=list)
source_references: list[PersonaSourceDocumentResponse] = Field(default_factory=list)
evidence_chunks: list[PersonaGenerationEvidence] = Field(default_factory=list)
class PersonaDraftDetail(PersonaReviewSummary):
demographics: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
presenting: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
@ -86,6 +170,7 @@ class PersonaDraftDetail(PersonaReviewSummary):
affect_baseline: dict[str, float]
ccd: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
dsm5_dimensional: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
triggers: dict[str, Any] = Field(json_schema_extra=JSON_OBJECT_FIELD)
def _first_text_value(data: dict[str, Any]) -> str:
@ -98,7 +183,10 @@ def _first_text_value(data: dict[str, Any]) -> str:
def _summary(entry: CatalogPersona) -> PersonaSummary:
card = entry.card
return PersonaSummary(
persona_id=entry.persona_id,
code=card.code,
version=entry.version,
status="approved",
display_name=card.display_name,
difficulty=card.difficulty,
theory_target=card.theory_target,
@ -138,6 +226,7 @@ def _draft_detail(entry: PersonaDraftRecord) -> PersonaDraftDetail:
affect_baseline=card.affect_baseline,
ccd=card.ccd,
dsm5_dimensional=card.dsm5_dimensional,
triggers=card.triggers,
)
@ -163,11 +252,430 @@ def _card_from_draft_payload(request: PersonaDraftPayload) -> PersonaCard:
affect_baseline=request.affect_baseline,
ccd=request.ccd,
dsm5_dimensional=request.dsm5_dimensional,
triggers=request.triggers,
source_provenance=request.source_provenance.strip(),
is_synthetic=request.is_synthetic,
)
def _safe_doc_segment(value: str) -> str:
safe = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip())
return safe.strip("-")[:120] or "source"
def _chunk_source_text(text: str, *, max_chars: int = 1800) -> list[str]:
paragraphs = [item.strip() for item in re.split(r"\n\s*\n", text) if item.strip()]
chunks: list[str] = []
current = ""
for paragraph in paragraphs or [text.strip()]:
pending = paragraph
while len(pending) > max_chars:
chunks.append(pending[:max_chars].strip())
pending = pending[max_chars:].strip()
if not pending:
continue
if current and len(current) + len(pending) + 2 > max_chars:
chunks.append(current.strip())
current = pending
else:
current = f"{current}\n\n{pending}".strip() if current else pending
if current:
chunks.append(current.strip())
return chunks
def _source_content_hash(chunks: list[dict[str, Any]]) -> str:
h = hashlib.sha256()
for chunk in chunks:
stable = {
"chunk_text": chunk.get("chunk_text") or "",
"context_prefix": chunk.get("context_prefix") or "",
"kb_kind": chunk.get("kb_kind") or "",
"visible_to": chunk.get("visible_to") or [],
"sensitivity": chunk.get("sensitivity"),
"meta": chunk.get("meta") or {},
}
h.update(json.dumps(stable, ensure_ascii=False, sort_keys=True).encode("utf-8"))
return h.hexdigest()
def _source_title(request: PersonaSourceDocumentRequest) -> str:
return (request.title or request.filename).strip()
async def _register_persona_source_document(
request: PersonaSourceDocumentRequest,
principal: Principal,
) -> PersonaSourceDocumentResponse:
"""Register a persona-authoring attachment as masked evaluator-only KB chunks."""
masked = mask_pii(request.text)
title = _source_title(request)
source_id = f"persona_authoring_{uuid.uuid4().hex[:16]}"
doc_uri = (
f"persona-authoring/{principal.user_id}/"
f"{source_id}/{_safe_doc_segment(request.filename)}"
)
kb_kind = PERSONA_SOURCE_KB_KIND.get(request.source_kind, "ko_context")
license_class: Literal["A", "B", "C", "D"] = "B"
external_llm_ok = True
chunks = [
{
"seq": index,
"chunk_text": chunk,
"heading_path": title,
"context_prefix": (
"페르소나 저작 첨부 자료. "
f"자료종류={request.source_kind}; 파일={request.filename}; "
"PII 마스킹본이며 evaluator 전용 근거로만 사용한다."
),
"kb_kind": kb_kind,
"visible_to": ["evaluator"],
"sensitivity": 2,
"meta": {
"persona_authoring": True,
"source_kind": request.source_kind,
"filename": request.filename,
"title": title,
"source_note": request.source_note,
"pii_entities_masked": masked.entities,
"license_class": license_class,
"external_llm_ok": external_llm_ok,
"raw_source_not_stored": True,
},
"token_count": max(1, len(chunk) // 4),
}
for index, chunk in enumerate(_chunk_source_text(masked.text_masked))
]
if not chunks:
raise HTTPException(status.HTTP_422_UNPROCESSABLE_ENTITY, detail="source document is empty")
content_hash = _source_content_hash(chunks)
index_req = rag.IndexRequest(
source_id=source_id,
doc_uri=doc_uri,
version=1,
content_hash=content_hash,
chunks=chunks,
)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
await conn.execute(
"""
INSERT INTO kb.source
(source_id, title, kb_kind, license_class, origin_path, citation, external_llm_ok)
VALUES ($1, $2, $3, 'B', $4, $5, TRUE)
ON CONFLICT (source_id) DO UPDATE SET
title = EXCLUDED.title,
origin_path = EXCLUDED.origin_path,
citation = EXCLUDED.citation
""",
source_id,
title,
kb_kind,
request.filename,
PERSONA_SOURCE_CITATION.get(request.source_kind, "교수자 첨부 PII 마스킹 파생본"),
)
result = await rag.index_document(conn, index_req)
except rag.NotConfigured as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"persona source RAG index unavailable: {exc}",
) from exc
except RuntimeError as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
return PersonaSourceDocumentResponse(
source_id=source_id,
doc_id=result.doc_id,
doc_uri=doc_uri,
title=title,
source_kind=request.source_kind,
kb_kind=kb_kind,
license_class=license_class,
external_llm_ok=external_llm_ok,
content_hash=content_hash,
chunk_count=len(chunks),
chunks_indexed=result.chunks_indexed,
embedded=result.embedded,
degraded=result.degraded,
pii_entities_masked=masked.entities,
)
async def _retrieve_persona_generation_evidence(
*,
source_ids: list[str],
query: str,
) -> list[PersonaGenerationEvidence]:
if not source_ids:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="persona generation requires at least one RAG source",
)
try:
async with acquire(ai_view=AIView.EVALUATOR.value) as conn:
result = await rag.search_kb(
conn,
query=query,
role=rag.AIRole.EVALUATOR,
k=8,
filters={"source_id": source_ids, "sensitivity_max": 2},
rerank=True,
)
try:
await rag.log_retrieval(conn, result=result, ai_role="evaluator")
except Exception:
pass
except rag.NotConfigured as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"persona source RAG search unavailable: {exc}",
) from exc
except RuntimeError as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
evidence = [
PersonaGenerationEvidence(
chunk_id=chunk.chunk_id,
source_id=chunk.source_id or "",
score=round(chunk.score, 6),
kb_kind=chunk.kb_kind,
heading_path=chunk.heading_path,
excerpt=(chunk.body or chunk.behavior_cue or "")[:1200],
)
for chunk in result.chunks
if (chunk.body or chunk.behavior_cue)
]
if not evidence:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="RAG evidence was not found for the selected persona sources",
)
return evidence
async def _load_persona_source_references(
source_ids: list[str],
) -> list[PersonaSourceDocumentResponse]:
if not source_ids:
return []
try:
async with acquire(role="admin") as conn:
rows = await conn.fetch(
"""
WITH latest_doc AS (
SELECT DISTINCT ON (source_id)
source_id, doc_id, doc_uri, content_hash
FROM kb.document
WHERE is_active AND source_id = ANY($1::text[])
ORDER BY source_id, version DESC
),
first_chunk AS (
SELECT DISTINCT ON (source_id)
source_id,
COALESCE(meta->>'source_kind', 'mixed_notes') AS source_kind
FROM kb.chunk
WHERE source_id = ANY($1::text[])
ORDER BY source_id, seq
),
chunk_counts AS (
SELECT source_id, COUNT(*)::int AS chunk_count
FROM kb.chunk
WHERE source_id = ANY($1::text[])
GROUP BY source_id
)
SELECT
s.source_id, s.title, s.kb_kind, s.license_class, s.external_llm_ok,
d.doc_id, d.doc_uri, d.content_hash,
COALESCE(fc.source_kind, 'mixed_notes') AS source_kind,
COALESCE(cc.chunk_count, 0) AS chunk_count
FROM kb.source s
LEFT JOIN latest_doc d ON d.source_id = s.source_id
LEFT JOIN first_chunk fc ON fc.source_id = s.source_id
LEFT JOIN chunk_counts cc ON cc.source_id = s.source_id
WHERE s.source_id = ANY($1::text[])
""",
source_ids,
)
except RuntimeError as exc:
raise HTTPException(status.HTTP_503_SERVICE_UNAVAILABLE, detail=f"DB not ready: {exc}") from exc
found = {str(row["source_id"]) for row in rows}
missing = [source_id for source_id in source_ids if source_id not in found]
if missing:
raise HTTPException(
status.HTTP_404_NOT_FOUND,
detail=f"persona source not found: {', '.join(missing)}",
)
references: list[PersonaSourceDocumentResponse] = []
for row in rows:
source_kind = str(row["source_kind"] or "mixed_notes")
if source_kind not in {"client_record", "textbook_guide", "mixed_notes"}:
source_kind = "mixed_notes"
references.append(
PersonaSourceDocumentResponse(
source_id=str(row["source_id"]),
doc_id=int(row["doc_id"]) if row["doc_id"] is not None else None,
doc_uri=str(row["doc_uri"] or ""),
title=str(row["title"] or row["source_id"]),
source_kind=source_kind, # type: ignore[arg-type]
kb_kind=str(row["kb_kind"] or "ko_context"),
license_class=str(row["license_class"] or "B"), # type: ignore[arg-type]
external_llm_ok=bool(row["external_llm_ok"]),
content_hash=str(row["content_hash"] or ""),
chunk_count=int(row["chunk_count"] or 0),
chunks_indexed=0,
embedded=True,
degraded=False,
pii_entities_masked=[],
)
)
return references
def _format_generation_evidence(evidence: list[PersonaGenerationEvidence]) -> str:
lines: list[str] = []
for index, item in enumerate(evidence, start=1):
heading = item.heading_path or item.source_id
lines.append(
f"[근거 {index}] source_id={item.source_id}; chunk_id={item.chunk_id}; "
f"score={item.score}; heading={heading}\n{item.excerpt}"
)
return "\n\n".join(lines)
def _persona_generation_schema() -> dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"properties": {
"draft": {
"type": "object",
"additionalProperties": False,
"properties": {
"code": {"type": "string"},
"display_name": {"type": "string"},
"difficulty": {"type": "string", "enum": ["easy", "moderate", "hard"]},
"theory_target": {"type": "array", "items": {"type": "string"}},
"demographics": {"type": "object"},
"presenting": {"type": "object"},
"history": {"type": "object"},
"big5": {"type": "object"},
"resistance": {"type": "object"},
"speech_style": {"type": "object"},
"affect_baseline": {"type": "object"},
"ccd": {"type": "object"},
"dsm5_dimensional": {"type": "object"},
"triggers": {"type": "object"},
"source_provenance": {"type": "string"},
"is_synthetic": {"type": "boolean"},
},
"required": [
"code",
"display_name",
"difficulty",
"theory_target",
"demographics",
"presenting",
"history",
"big5",
"resistance",
"speech_style",
"affect_baseline",
"ccd",
"dsm5_dimensional",
"triggers",
"source_provenance",
"is_synthetic",
],
},
"source_summary": {"type": "string"},
"warnings": {"type": "array", "items": {"type": "string"}},
},
"required": ["draft", "source_summary", "warnings"],
}
def _json_payload_from_generation(text: str) -> dict[str, Any]:
try:
parsed = json.loads(text)
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
try:
parsed = json.loads(text[start : end + 1])
return parsed if isinstance(parsed, dict) else {}
except json.JSONDecodeError:
return {}
return {}
def _float_dict(value: Any) -> dict[str, float]:
if not isinstance(value, dict):
return {}
result: dict[str, float] = {}
for key, item in value.items():
if isinstance(item, (int, float)):
result[str(key)] = float(item)
return result
def _coerce_generated_draft(
payload: dict[str, Any],
request: PersonaDraftGenerateRequest,
) -> PersonaDraftPayload:
raw = payload.get("draft") if isinstance(payload.get("draft"), dict) else payload
if not isinstance(raw, dict):
raw = {}
theory_target = raw.get("theory_target")
theory_values = (
[str(item).strip().lower() for item in theory_target if str(item).strip()]
if isinstance(theory_target, list)
else [value.strip().lower() for value in request.theory_target if value.strip()]
)
code = str(raw.get("code") or request.code_hint or "").strip().upper()
display_name = str(raw.get("display_name") or request.display_name_hint or "자료 기반 새 페르소나").strip()
difficulty = str(raw.get("difficulty") or request.difficulty)
if difficulty not in {"easy", "moderate", "hard"}:
difficulty = request.difficulty
return PersonaDraftPayload(
code=code or "P",
display_name=display_name,
difficulty=difficulty, # type: ignore[arg-type]
theory_target=theory_values or ["humanistic"],
demographics=_json_object(raw.get("demographics")),
presenting=_json_object(raw.get("presenting")),
history=_json_object(raw.get("history")),
big5=_float_dict(raw.get("big5")) or {"O": 0.5, "C": 0.5, "E": 0.5, "A": 0.5, "N": 0.5},
resistance=_float_dict(raw.get("resistance"))
or {
"base_resistance": 0.5,
"unlock_rate": 0.1,
"decay_floor": 0.05,
"silence_prob": 0.15,
"deflection_prob": 0.25,
},
speech_style=_json_object(raw.get("speech_style")),
affect_baseline=_float_dict(raw.get("affect_baseline"))
or {
"negative_affect": 0.45,
"hopelessness": 0.2,
"anhedonia": 0.2,
"sleep": 0.2,
"anxiety": 0.35,
"suicide_ideation_stage": 1,
},
ccd=_json_object(raw.get("ccd")),
dsm5_dimensional=_json_object(raw.get("dsm5_dimensional")),
triggers=_json_object(raw.get("triggers")),
source_provenance=str(raw.get("source_provenance") or f"masked {request.source_kind}"),
is_synthetic=bool(raw.get("is_synthetic", True)),
submit_for_review=False,
)
def _json_object(value: Any) -> dict[str, Any]:
return value if isinstance(value, dict) else {}
def _ensure_teacher_or_admin(principal: Principal) -> None:
if principal.role not in {Role.TEACHER, Role.ADMIN}:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only teachers and admins can review personas")
@ -231,6 +739,196 @@ async def create_persona_draft_route(
return _review_summary(created)
@router.post("/{persona_id}/revisions", response_model=PersonaDraftDetail, status_code=status.HTTP_201_CREATED)
async def create_persona_revision_route(
persona_id: str,
request: PersonaRevisionRequest,
principal: TeacherOrAdmin,
) -> PersonaDraftDetail:
"""Clone an approved/system persona into an editable draft version."""
_ensure_teacher_or_admin(principal)
try:
record = await create_persona_revision_from_existing(
persona_id=persona_id,
author_id=principal.user_id,
role=principal.role.value,
submit_for_review=request.submit_for_review,
)
except ValueError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona revision database unavailable",
) from exc
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="approved persona not found")
return _draft_detail(record)
@router.delete("/{persona_id}", response_model=PersonaReviewSummary)
async def archive_persona_route(
persona_id: str,
principal: TeacherOrAdmin,
) -> PersonaReviewSummary:
"""Archive a persona code family instead of hard-deleting historical cards."""
_ensure_teacher_or_admin(principal)
try:
archived = await archive_persona_family(
persona_id=persona_id,
archiver_id=principal.user_id,
role=principal.role.value,
)
except ValueError as exc:
raise HTTPException(status.HTTP_403_FORBIDDEN, detail=str(exc)) from exc
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="persona archive database unavailable",
) from exc
if archived is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="persona not found")
return _review_summary(archived)
@router.post("/sources", response_model=PersonaSourceDocumentResponse, status_code=status.HTTP_201_CREATED)
async def create_persona_source_route(
request: PersonaSourceDocumentRequest,
principal: TeacherOrAdmin,
) -> PersonaSourceDocumentResponse:
"""Attach a source document to the persona-authoring KB before draft generation."""
_ensure_teacher_or_admin(principal)
return await _register_persona_source_document(request, principal)
@router.post("/drafts/generate", response_model=PersonaDraftGenerateResponse)
async def generate_persona_draft_route(
request: PersonaDraftGenerateRequest,
principal: TeacherOrAdmin,
) -> PersonaDraftGenerateResponse:
"""Generate an editable persona draft from evaluator-only RAG evidence."""
_ensure_teacher_or_admin(principal)
source_references: list[PersonaSourceDocumentResponse] = []
source_ids = [item.strip() for item in request.source_ids if item.strip()]
pii_entities: list[str] = []
if request.source_text:
inline_source = await _register_persona_source_document(
PersonaSourceDocumentRequest(
filename="inline-persona-source.txt",
source_kind=request.source_kind,
text=request.source_text,
title="붙여넣은 페르소나 저작 자료",
source_note=request.generation_goal,
),
principal,
)
source_references.append(inline_source)
source_ids.append(inline_source.source_id)
pii_entities.extend(inline_source.pii_entities_masked)
if not source_ids:
raise HTTPException(
status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="source_ids or source_text is required for RAG-based persona generation",
)
known_refs = {item.source_id: item for item in source_references}
unknown_source_ids = [source_id for source_id in source_ids if source_id not in known_refs]
if unknown_source_ids:
for item in await _load_persona_source_references(unknown_source_ids):
known_refs[item.source_id] = item
source_references = [known_refs[source_id] for source_id in source_ids if source_id in known_refs]
blocked_sources = [item.source_id for item in source_references if not item.external_llm_ok]
if blocked_sources:
raise HTTPException(
status.HTTP_409_CONFLICT,
detail=(
"selected persona sources are not allowed for external LLM generation: "
+ ", ".join(blocked_sources)
),
)
code_hint = (request.code_hint or "").strip().upper()
display_hint = (request.display_name_hint or "").strip()
evidence_query = "\n".join(
[
request.generation_goal or "교육용 가상내담자 페르소나 초안 생성",
request.source_kind,
request.difficulty,
" ".join(request.theory_target),
display_hint,
]
).strip()
evidence = await _retrieve_persona_generation_evidence(
source_ids=source_ids,
query=evidence_query or "페르소나 저작 근거",
)
evidence_text = _format_generation_evidence(evidence)
prompt = (
"너는 Vignette 임상 콘텐츠 저작 보조자다. 아래 RAG 근거 청크만 바탕으로 교육용 "
"가상내담자 페르소나 초안을 만든다. 첨부 원문은 KB 문서가 SSOT이며, 근거 밖 내용을 "
"임의로 꾸며 핵심 임상 정보처럼 쓰지 않는다. 실제 개인정보는 이미 마스킹됐으며, "
"원문 표현을 복사하지 말고 "
"범주화·합성화된 임상 훈련용 설정으로 변환한다. CCD/DSM/역린은 런타임 내부 설정이므로 "
"내담자 발화에 직접 노출되지 않는 형태로 작성한다.\n\n"
f"자료 종류: {request.source_kind}\n"
f"RAG source_ids: {source_ids}\n"
f"코드 힌트: {code_hint or '미정'}\n"
f"표시명 힌트: {display_hint or '미정'}\n"
f"난이도: {request.difficulty}\n"
f"대상 이론: {request.theory_target}\n"
f"저작 목표: {request.generation_goal or '첫 편집 가능한 초안 생성'}\n\n"
"[RAG 근거 청크]\n"
f"{evidence_text}"
)
req = GenerateRequest(
ai_role="evaluator",
messages=[
EngineMessage(
role="system",
content=(
"출력은 반드시 structured_schema를 따른다. code는 P숫자 형식을 선호하되 "
"힌트가 없으면 빈 문자열 대신 임시값 P로 둔다. source_provenance에는 "
"RAG source_id와 첨부 근거 기반 초안임을 남긴다. evidence chunk id를 "
"임상 필드 본문에 그대로 노출하지 않는다."
),
),
EngineMessage(role="user", content=prompt),
],
max_tokens=2200,
temperature=0.2,
structured_schema=_persona_generation_schema(),
metadata={
"feature": "persona_draft_generation",
"source_kind": request.source_kind,
"source_ids": source_ids,
},
)
try:
response = await engine_client.generate(req)
except EngineError as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"persona draft generator unavailable: {exc}",
) from exc
payload = response.structured or _json_payload_from_generation(response.text)
draft = _coerce_generated_draft(payload, request)
provenance = (
f"RAG sources={','.join(source_ids)}; "
f"chunks={','.join(str(item.chunk_id) for item in evidence)}"
)
if draft.source_provenance and draft.source_provenance not in provenance:
provenance = f"{provenance}; {draft.source_provenance}"
draft.source_provenance = provenance[:240]
summary = str(payload.get("source_summary") or "")
warnings = payload.get("warnings") if isinstance(payload.get("warnings"), list) else []
return PersonaDraftGenerateResponse(
draft=draft,
source_summary=summary,
warnings=[str(item) for item in warnings],
pii_entities_masked=pii_entities,
source_references=source_references,
evidence_chunks=evidence,
)
@router.get("/drafts/{persona_id}", response_model=PersonaDraftDetail)
async def get_persona_draft_route(
persona_id: str,

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,309 @@
"""Public share/unfurl routes.
공개 공유 URL은 세션 권한을 우회하지 않는다. 학습자가 명시적으로 생성한
토큰으로 app.session_share_link의 sanitized payload만 읽고, 원문 축어록은 조회하지 않는다.
"""
from __future__ import annotations
import html
import json
import re
from typing import Any
from fastapi import APIRouter, HTTPException, Request, Response, status
from fastapi.responses import HTMLResponse, PlainTextResponse
from pydantic import BaseModel, Field
from .. import session_persistence
router = APIRouter(tags=["share"])
_TOKEN_RE = re.compile(r"^[A-Za-z0-9_-]{32,160}$")
_SHARE_HEADERS = {
"cache-control": "public, max-age=300",
"x-robots-tag": "noindex, noarchive, max-snippet:160",
}
class PublicSessionShareResponse(BaseModel):
title: str
description: str
summary: str
imageUrl: str
appUrl: str
clientName: str
persona: str
date: str
durationLabel: str
reachedPhase: str
sessionSignal: str
reviewReady: bool = False
goodMoments: list[str] = Field(default_factory=list)
growthPoints: list[str] = Field(default_factory=list)
worksheetHighlights: list[dict[str, str]] = Field(default_factory=list)
privacy: str = ""
def _safe_payload(payload: dict[str, Any]) -> PublicSessionShareResponse:
return PublicSessionShareResponse(
title=str(payload.get("title") or "Vignette 회기 리뷰"),
description=str(payload.get("description") or "AI 심리상담 시뮬레이션 회기 리뷰 요약"),
summary=str(payload.get("summary") or ""),
imageUrl=str(payload.get("imageUrl") or ""),
appUrl=str(payload.get("appUrl") or ""),
clientName=str(payload.get("clientName") or "내담자"),
persona=str(payload.get("persona") or ""),
date=str(payload.get("date") or ""),
durationLabel=str(payload.get("durationLabel") or ""),
reachedPhase=str(payload.get("reachedPhase") or ""),
sessionSignal=str(payload.get("sessionSignal") or ""),
reviewReady=bool(payload.get("reviewReady")),
goodMoments=[str(item) for item in payload.get("goodMoments") or []][:3],
growthPoints=[str(item) for item in payload.get("growthPoints") or []][:3],
worksheetHighlights=[
{
"section": str(item.get("section") or ""),
"label": str(item.get("label") or ""),
"value": str(item.get("value") or ""),
}
for item in (payload.get("worksheetHighlights") or [])
if isinstance(item, dict)
][:4],
privacy=str(payload.get("privacy") or ""),
)
async def _load_share_or_404(token: str) -> PublicSessionShareResponse:
if not _TOKEN_RE.match(token):
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
record = await session_persistence.load_public_session_share(
session_persistence.share_token_hash(token)
)
if record is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="share not found")
return _safe_payload(dict(record.get("payload") or {}))
def _request_url(request: Request) -> str:
return str(request.url)
def _json_ld(share: PublicSessionShareResponse, url: str) -> str:
payload = {
"@context": "https://schema.org",
"@type": "CreativeWork",
"name": share.title,
"description": share.description,
"url": url,
"image": share.imageUrl,
"inLanguage": "ko-KR",
"educationalUse": "AI counseling simulation review",
"isAccessibleForFree": True,
"provider": {
"@type": "Organization",
"name": "Vignette",
},
}
return json.dumps(payload, ensure_ascii=False)
def _meta(name: str, content: str, *, prop: bool = False) -> str:
attr = "property" if prop else "name"
return f'<meta {attr}="{html.escape(name)}" content="{html.escape(content, quote=True)}">'
def _list_items(values: list[str]) -> str:
if not values:
return "<li>아직 공유 가능한 항목이 없습니다.</li>"
return "".join(f"<li>{html.escape(value)}</li>" for value in values)
def _worksheet_items(values: list[dict[str, str]]) -> str:
if not values:
return "<li>사례개념화 워크시트 핵심값은 아직 비어 있습니다.</li>"
return "".join(
"<li>"
f"<b>{html.escape(item['label'])}</b>"
f"<span>{html.escape(item['value'])}</span>"
"</li>"
for item in values
)
def _share_html(share: PublicSessionShareResponse, url: str) -> str:
title = html.escape(share.title)
description = html.escape(share.description)
image = html.escape(share.imageUrl, quote=True)
app_url = html.escape(share.appUrl or "https://vignette.chanpaca.net", quote=True)
return f"""<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{title}</title>
{_meta("robots", "noindex, noarchive, max-snippet:160")}
{_meta("description", share.description)}
{_meta("og:type", "article", prop=True)}
{_meta("og:site_name", "Vignette", prop=True)}
{_meta("og:title", share.title, prop=True)}
{_meta("og:description", share.description, prop=True)}
{_meta("og:url", url, prop=True)}
{_meta("og:image", share.imageUrl, prop=True)}
{_meta("og:image:width", "1672", prop=True)}
{_meta("og:image:height", "941", prop=True)}
{_meta("twitter:card", "summary_large_image")}
{_meta("twitter:title", share.title)}
{_meta("twitter:description", share.description)}
{_meta("twitter:image", share.imageUrl)}
<script type="application/ld+json">{_json_ld(share, url)}</script>
<style>
:root {{
color-scheme: light dark;
--bg: #f8f5ef;
--surface: #ffffff;
--ink: #172424;
--muted: #65706d;
--accent: #2f6f63;
--line: #e4ddd2;
}}
body {{
margin: 0;
min-height: 100vh;
background: var(--bg);
color: var(--ink);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans KR", sans-serif;
line-height: 1.55;
}}
main {{
width: min(920px, calc(100% - 32px));
margin: 0 auto;
padding: 44px 0;
}}
.hero {{
overflow: hidden;
border: 1px solid var(--line);
border-radius: 14px;
background: var(--surface);
box-shadow: 0 14px 40px rgba(23, 36, 36, .08);
}}
.hero img {{
display: block;
width: 100%;
aspect-ratio: 1672 / 941;
object-fit: cover;
}}
.body {{ padding: 28px; }}
.eyebrow {{
margin: 0 0 8px;
color: var(--accent);
font-size: 12px;
font-weight: 800;
letter-spacing: .08em;
text-transform: uppercase;
}}
h1 {{ margin: 0; font-size: clamp(26px, 4vw, 42px); line-height: 1.2; letter-spacing: 0; }}
.desc {{ margin: 14px 0 0; color: var(--muted); font-size: 17px; }}
.facts {{
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin: 24px 0;
}}
.fact {{ border: 1px solid var(--line); border-radius: 10px; padding: 12px; background: color-mix(in srgb, var(--surface) 82%, var(--bg)); }}
.fact b {{ display: block; font-size: 12px; color: var(--muted); }}
.fact span {{ display: block; margin-top: 4px; font-weight: 750; }}
.grid {{ display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }}
section {{ border-top: 1px solid var(--line); padding-top: 18px; }}
h2 {{ margin: 0 0 10px; font-size: 17px; }}
ul {{ margin: 0; padding-left: 20px; color: var(--ink); }}
li + li {{ margin-top: 8px; }}
li span {{ display: block; color: var(--muted); }}
.privacy {{ margin-top: 22px; color: var(--muted); font-size: 13px; }}
.cta {{
display: inline-flex;
align-items: center;
min-height: 42px;
margin-top: 22px;
padding: 0 16px;
border-radius: 8px;
background: var(--accent);
color: #fff;
text-decoration: none;
font-weight: 800;
}}
@media (max-width: 720px) {{
main {{ width: min(100% - 20px, 920px); padding: 20px 0; }}
.body {{ padding: 20px; }}
.facts, .grid {{ grid-template-columns: 1fr; }}
}}
</style>
</head>
<body>
<main>
<article class="hero">
<img src="{image}" alt="">
<div class="body">
<p class="eyebrow">Vignette session review</p>
<h1>{title}</h1>
<p class="desc">{description}</p>
<div class="facts" aria-label="회기 요약">
<div class="fact"><b>날짜</b><span>{html.escape(share.date or "-")}</span></div>
<div class="fact"><b>시간</b><span>{html.escape(share.durationLabel or "-")}</span></div>
<div class="fact"><b>도달 단계</b><span>{html.escape(share.reachedPhase or "-")}</span></div>
<div class="fact"><b>상태</b><span>{html.escape(share.sessionSignal or "-")}</span></div>
</div>
<div class="grid">
<section>
<h2>강점 요약</h2>
<ul>{_list_items(share.goodMoments)}</ul>
</section>
<section>
<h2>개선 요약</h2>
<ul>{_list_items(share.growthPoints)}</ul>
</section>
</div>
<section style="margin-top:18px">
<h2>사례개념화 핵심값</h2>
<ul>{_worksheet_items(share.worksheetHighlights)}</ul>
</section>
<p class="privacy">{html.escape(share.privacy)}</p>
<a class="cta" href="{app_url}">Vignette 열기</a>
</div>
</article>
</main>
</body>
</html>"""
@router.get("/share/session/{token}", response_class=HTMLResponse, name="get_public_session_share")
async def get_public_session_share(token: str, request: Request) -> HTMLResponse:
share = await _load_share_or_404(token)
return HTMLResponse(_share_html(share, _request_url(request)), headers=_SHARE_HEADERS)
@router.get("/share/session/{token}/summary", response_model=PublicSessionShareResponse)
async def get_public_session_share_summary(token: str, response: Response) -> PublicSessionShareResponse:
for key, value in _SHARE_HEADERS.items():
response.headers[key] = value
return await _load_share_or_404(token)
@router.get("/robots.txt", include_in_schema=False)
async def robots_txt() -> PlainTextResponse:
body = "\n".join(
[
"User-agent: *",
"Disallow: /auth/",
"Disallow: /admin/",
"Disallow: /sessions/",
"Disallow: /teacher/",
"Disallow: /users/",
"Disallow: /eval/",
"Disallow: /voice/",
"Disallow: /kb/",
"Disallow: /share/",
"",
]
)
return PlainTextResponse(body, headers={"cache-control": "public, max-age=3600"})

View file

@ -2,15 +2,15 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Any
from typing import Annotated, Literal
from fastapi import APIRouter, Depends
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel, Field
from .. import session_persistence
from ..deps import Principal, Role, require_role
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import session_metrics
from ..store import InProcSession, store
router = APIRouter(prefix="/teacher", tags=["teacher"])
@ -32,6 +32,23 @@ class TeacherSessionSummary(BaseModel):
client_turn_count: int
started_at: str
ended_at: str | None = None
review_status: Literal["pending", "viewed", "closed"] = "pending"
review_note: str | None = None
reviewed_at: str | None = None
class TeacherSessionReviewStatusRequest(BaseModel):
status: Literal["viewed", "closed"] = "closed"
note: str = Field(default="", max_length=2000)
class TeacherSessionReviewStatusResponse(BaseModel):
session_id: str
status: Literal["pending", "viewed", "closed"] = "pending"
note: str = ""
reviewer_id: str | None = None
reviewed_at: str | None = None
updated_at: str | None = None
class TeacherGrowthPoint(BaseModel):
@ -91,168 +108,63 @@ class TeacherDashboardResponse(BaseModel):
message: str
_APPROPRIATENESS_SCORE = {
"neg": 0.0,
"neutral": 0.5,
"pos": 1.0,
}
def _iso(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
def _learner_label(learner_id: str) -> str:
suffix = learner_id[-6:] if len(learner_id) > 6 else learner_id
return f"학습자 {suffix}"
def _safe_float(value: object) -> float | None:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def _avg(values: list[float]) -> float | None:
if not values:
return None
return round(sum(values) / len(values), 3)
def _turn_eval(turn: Any) -> dict[str, Any] | None:
ev = getattr(turn, "evaluation", None)
return ev if isinstance(ev, dict) else None
def _turn_score(ev: dict[str, Any]) -> float | None:
raw = str(ev.get("appropriateness") or "").strip().lower()
return _APPROPRIATENESS_SCORE.get(raw)
def _turn_rapport(ev: dict[str, Any]) -> float | None:
value = _safe_float(ev.get("rapport_signal"))
if value is None:
return None
return max(-1.0, min(1.0, value))
def _turn_techniques(ev: dict[str, Any]) -> list[str]:
raw = ev.get("techniques")
if not isinstance(raw, list):
return []
labels: list[str] = []
for item in raw:
if isinstance(item, dict):
label = item.get("label") or item.get("name") or item.get("id")
else:
label = item
if label:
labels.append(str(label))
return labels
def _session_growth_point(sess: InProcSession) -> TeacherGrowthPoint:
scores: list[float] = []
rapports: list[float] = []
technique_count = 0
watch_count = 0
for turn in sess.turns:
if turn.speaker != "counselor":
continue
ev = _turn_eval(turn)
if ev is None:
continue
score = _turn_score(ev)
if score is not None:
scores.append(score)
if score < 1.0:
watch_count += 1
rapport = _turn_rapport(ev)
if rapport is not None:
rapports.append(rapport)
technique_count += len(_turn_techniques(ev))
def _growth_point(point: session_metrics.SessionGrowthPoint) -> TeacherGrowthPoint:
return TeacherGrowthPoint(
session_id=sess.session_id,
session_no=sess.session_no,
persona_code=sess.persona_code,
stage=sess.state.stage.value,
started_at=_iso(sess.created_at) or "",
ended_at=_iso(sess.ended_at),
score=_avg(scores),
rapport=_avg(rapports),
technique_count=technique_count,
watch_count=watch_count,
session_id=point.session_id,
session_no=point.session_no,
persona_code=point.persona_code,
stage=point.stage,
started_at=point.started_at,
ended_at=point.ended_at,
score=point.score,
rapport=point.rapport,
technique_count=point.technique_count,
watch_count=point.watch_count,
)
def _build_learner_growth(sessions: list[InProcSession]) -> list[TeacherLearnerGrowth]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
grouped.setdefault(sess.learner_id, []).append(sess)
result: list[TeacherLearnerGrowth] = []
for learner_id, learner_sessions in grouped.items():
ordered = sorted(learner_sessions, key=lambda sess: sess.created_at)
points = [_session_growth_point(sess) for sess in ordered]
scored = [point for point in points if point.score is not None]
rapport_values = [point.rapport for point in points if point.rapport is not None]
technique_counts: dict[str, int] = {}
for sess in ordered:
for turn in sess.turns:
if turn.speaker != "counselor":
continue
ev = _turn_eval(turn)
if ev is None:
continue
for label in _turn_techniques(ev):
technique_counts[label] = technique_counts.get(label, 0) + 1
first_score = scored[0].score if scored else None
latest_score = scored[-1].score if scored else None
score_delta: float | None = None
trend = "insufficient"
if first_score is not None and latest_score is not None:
score_delta = round(latest_score - first_score, 3)
if len(scored) >= 2:
if score_delta >= 0.1:
trend = "up"
elif score_delta <= -0.1:
trend = "down"
else:
trend = "flat"
latest_session = ordered[-1]
top_techniques = [
label
for label, _count in sorted(
technique_counts.items(),
key=lambda item: (-item[1], item[0]),
)[:3]
]
result.append(
TeacherLearnerGrowth(
learner_id=learner_id,
learner_label=_learner_label(learner_id),
sessions=len(ordered),
ended_sessions=sum(1 for sess in ordered if sess.ended),
latest_at=_iso(latest_session.ended_at or latest_session.created_at) or "",
first_score=first_score,
latest_score=latest_score,
score_delta=score_delta,
avg_score=_avg([point.score for point in scored if point.score is not None]),
avg_rapport=_avg([value for value in rapport_values if value is not None]),
trend=trend,
top_techniques=top_techniques,
points=points[-6:],
)
metrics = session_metrics.build_learner_growth(
sessions,
learner_label=_learner_label,
limit=12,
)
return [
TeacherLearnerGrowth(
learner_id=item.learner_id,
learner_label=item.learner_label,
sessions=item.sessions,
ended_sessions=item.ended_sessions,
latest_at=item.latest_at,
first_score=item.first_score,
latest_score=item.latest_score,
score_delta=item.score_delta,
avg_score=item.avg_score,
avg_rapport=item.avg_rapport,
trend=item.trend,
top_techniques=item.top_techniques,
points=[_growth_point(point) for point in item.points],
)
return sorted(result, key=lambda item: item.latest_at, reverse=True)[:12]
for item in metrics
]
def _summary(sess: InProcSession) -> TeacherSessionSummary:
def _review_status_value(record: dict[str, object] | None) -> Literal["pending", "viewed", "closed"]:
value = str((record or {}).get("status") or "pending")
if value in {"viewed", "closed"}:
return value # type: ignore[return-value]
return "pending"
def _summary(
sess: InProcSession,
review_status: dict[str, object] | None = None,
) -> TeacherSessionSummary:
learner_turns = sum(1 for turn in sess.turns if turn.speaker == "counselor")
client_turns = sum(1 for turn in sess.turns if turn.speaker == "client")
return TeacherSessionSummary(
@ -267,8 +179,11 @@ def _summary(sess: InProcSession) -> TeacherSessionSummary:
turn_count=len(sess.turns),
learner_turn_count=learner_turns,
client_turn_count=client_turns,
started_at=_iso(sess.created_at) or "",
ended_at=_iso(sess.ended_at),
started_at=session_metrics.iso_datetime(sess.created_at) or "",
ended_at=session_metrics.iso_datetime(sess.ended_at),
review_status=_review_status_value(review_status),
review_note=str(review_status.get("note") or "") if review_status else None,
reviewed_at=str(review_status.get("reviewed_at") or "") if review_status else None,
)
@ -282,8 +197,19 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
if not durable:
require_runtime_fallback_allowed("teacher dashboard")
sessions = sorted(store.list(), key=lambda sess: sess.created_at, reverse=True)
summaries = [_summary(sess) for sess in sessions]
pending_reviews = [item for item in summaries if item.status == "ended"]
review_statuses, _ = await session_persistence.list_session_review_statuses(
[sess.session_id for sess in sessions if sess.ended],
principal,
)
summaries = [
_summary(sess, review_statuses.get(sess.session_id))
for sess in sessions
]
pending_reviews = [
item
for item in summaries
if item.status == "ended" and item.review_status != "closed"
]
learners = {sess.learner_id for sess in sessions}
learner_growth = _build_learner_growth(sessions)
safety_alerts: list[TeacherSafetyAlert] = []
@ -331,3 +257,46 @@ async def teacher_dashboard(principal: TeacherPrincipal) -> TeacherDashboardResp
recent_sessions=summaries[:20],
message=message,
)
@router.put(
"/sessions/{session_id}/review-status",
response_model=TeacherSessionReviewStatusResponse,
)
async def update_session_review_status(
session_id: str,
request: TeacherSessionReviewStatusRequest,
principal: TeacherPrincipal,
) -> TeacherSessionReviewStatusResponse:
sess = await session_persistence.load_session(
session_id,
principal,
allow_ended=True,
)
if sess is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="session not found")
if not sess.ended and request.status == "closed":
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="active sessions cannot be closed as reviewed",
)
saved, _ = await session_persistence.save_session_review_status(
session_id=session_id,
reviewer_id=principal.user_id,
status=request.status,
note=request.note,
principal=principal,
)
if saved is None:
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail="review status unavailable",
)
return TeacherSessionReviewStatusResponse(
session_id=session_id,
status=_review_status_value(saved),
note=str(saved.get("note") or ""),
reviewer_id=str(saved.get("reviewer_id") or "") or None,
reviewed_at=str(saved.get("reviewed_at") or "") or None,
updated_at=str(saved.get("updated_at") or "") or None,
)

View file

@ -2,30 +2,173 @@
from __future__ import annotations
from fastapi import APIRouter, HTTPException, status
import secrets
from pathlib import Path
from typing import Literal
from fastapi import APIRouter, File, HTTPException, UploadFile, status
from pydantic import BaseModel, Field
from ..auth_sessions import DEFAULT_AFFILIATION, get_managed_user, update_managed_user
from ..db import get_pool
from ..deps import CurrentPrincipal
from ..auth_types import RoleName
from ..auth_sessions import (
DEFAULT_AFFILIATION,
ManagedUserPatch,
get_managed_user,
record_user_consent,
update_managed_user,
)
from ..config import settings
from ..db import acquire, get_pool
from ..deps import CurrentPrincipal, Role
from ..runtime_policy import require_runtime_fallback_allowed
from ..services.voice import PRESET_RATE, PRESET_TO_OPENAI_VOICE
router = APIRouter(prefix="/users", tags=["users"])
TERMS_VERSION = "terms-draft-2026-06-27"
PRIVACY_VERSION = "privacy-draft-2026-06-27"
AVATAR_MAX_BYTES = 3 * 1024 * 1024
AVATAR_CONTENT_TYPES = {
"image/png": ("png", b"\x89PNG\r\n\x1a\n"),
"image/jpeg": ("jpg", b"\xff\xd8\xff"),
"image/webp": ("webp", b"RIFF"),
}
TERMS_BODY = """Vignette 서비스 이용약관 초안
1. 목적
약관은 Vignette가 제공하는 AI 심리상담 시뮬레이션 훈련 플랫폼의 이용 조건, 권리와 의무, 책임 범위를 정한다. Vignette는 상담 수련생의 교육, 실습, 교수자 피드백, 운영 품질 관리를 위한 비치료 교육 도구이며 실제 진단, 치료, 응급 위기 개입을 대체하지 않는다.
2. 계정과 이용 자격
서비스는 한신대학교 산학협력 교육 과정, 승인된 연구/수업, 운영자가 허용한 기관 계정에 한해 제공된다. 사용자는 본인 계정으로만 로그인해야 하며 타인의 Google 계정, 학교 계정, 세션 쿠키를 빌리거나 공유해서는 된다. 교수자와 관리자는 운영자가 별도로 지정한 이메일 allowlist 또는 관리자 사용자 관리 절차를 통해 권한을 부여받는다.
3. 역할별 이용 범위
학습자는 AI 내담자와의 모의 회기, 회기 종료 피드백, 사례개념화 워크시트 작성 기능을 사용할 있다. 교수자는 담당 코호트 또는 승인 범위 안에서 학습자의 회기 요약, 안전 알림, 성장 지표, 제출물을 검토할 있다. 관리자는 사용자 권한, 시스템 상태, 비용, 페르소나 승인, 감사 기록을 관리한다.
4. AI 시뮬레이션의 성격
AI 내담자는 교육용 페르소나를 연기한다. AI 응답은 상담 훈련을 위한 시뮬레이션 산출물이며 의료, 임상, 법률, 행정 판단으로 사용해서는 된다. 사용자는 실제 위기 상황, 자해·자살 위험, 폭력 위험, 학대 의심 즉시 개입이 필요한 상황에서는 119, 112, 109 또는 기관의 위기 대응 절차를 우선해야 한다.
5. 사용자 의무
사용자는 허위 정보 입력, 타인의 개인정보 입력, 비인가 접근, 취약점 탐색, 데이터 무단 반출, 모델 프롬프트 탈취, 자동화된 대량 요청, 실존 인물 사칭, 교육 목적을 벗어난 민감 정보 입력을 해서는 된다. 상담 실습 실제 제3자의 이름, 연락처, 주소, 주민등록번호, 진료 정보 불필요한 개인정보는 입력하지 않는 것을 원칙으로 한다.
6. 콘텐츠와 기록
학습자가 입력한 발화, AI 내담자 응답, 회기 메타데이터, 평가 결과, 사례개념화 워크시트는 교육 운영, 피드백 제공, 품질 개선, 안전 관리, 연구 검증을 위해 저장될 있다. 원문은 접근 권한과 RLS 정책에 따라 제한되고, 외부 AI 처리 경로에는 가능한 범위에서 마스킹된 텍스트를 사용한다.
7. 서비스 변경과 중단
운영자는 교육 일정, 보안, 장애, 비용, 외부 AI 제공자 상태, 학교 또는 연구기관 정책에 따라 기능을 변경하거나 일시 중단할 있다. 운영자는 중대한 변경이 있을 가능한 범위에서 사전에 안내한다.
8. 권한 회수와 이용 제한
운영자는 계정 오용, 보안 위험, 허위 정보, 교육 목적 사용, 법령 또는 기관 정책 위반이 확인되면 이용을 제한하거나 세션을 무효화할 있다. 교수자와 관리자의 권한은 직무 변경, 코호트 변경, 산학협력 범위 변경, 퇴직 또는 운영자 결정에 따라 조정될 있다.
9. 책임 제한
서비스는 교육과 연구 목적의 보조 도구다. AI 응답의 완전성, 임상적 정확성, 특정 학습 성과를 보장하지 않는다. 다만 운영자는 개인정보 보호, 접근통제, 감사, 안전 게이트 합리적인 보호조치를 유지하기 위해 노력한다.
10. 준거와 개정
약관은 대한민국 법령과 한신대학교 산학협력 운영 기준을 따른다. 문서는 운영 초안이며, 최종 약관은 윤찬, 한신대학교 담당자, 법무/개인정보 검토 결과에 따라 수정될 있다.
"""
PRIVACY_BODY = """Vignette 개인정보 수집·이용 및 처리방침 초안
1. 처리 목적
Vignette는 AI 심리상담 시뮬레이션 교육 운영, 사용자 식별, 역할별 권한 관리, 교수자 피드백, 회기 기록 보존, 사례개념화 과제 관리, 안전 이벤트 대응, 서비스 품질 개선, 연구·평가 지표 산출, 법령과 기관 정책 준수를 위해 개인정보를 처리한다.
2. 수집 항목
필수 항목은 로그인 이메일, 이름, 닉네임, 자기소개, 소속, 학과, 학년 또는 직위, 연락처, 주소 또는 우편물 수령지, 역할, 코호트, 로그인/접속 기록, 동의 이력, 회기 발화와 AI 응답, 회기 메타데이터, 평가 피드백 결과다. 선택 항목으로 사용자가 업로드한 프로필 아바타 이미지와 저장 URL을 처리할 있다. 이메일은 Google 또는 학교 계정 로그인으로 확인되므로 별도 입력을 받지 않는다.
3. 민감한 교육 데이터
상담 실습 과정에서 심리 상태, 위기 표현, 사례개념화 내용, 음성 입력 메타데이터, 교수자 코멘트가 생성될 있다. 이는 실제 치료기록이 아니라 교육용 시뮬레이션 기록이지만, 재식별 위험과 민감성을 고려해 접근권한을 제한하고 감사 로그를 남긴다.
4. 보유와 이용 기간
개인정보와 학습 기록은 산학협력 교육·연구 운영, 성과 검증, 감사, 분쟁 대응에 필요한 기간 동안 보유한다. 구체적인 보유 기간, 파기 주기, 연구 데이터 익명화 기준은 기관 검토 확정한다. 사용 중지 또는 권한 회수 후에도 법령상 의무, 연구 검증, 감사 목적상 필요한 최소 기록은 별도 기간 동안 보관될 있다.
5. 제3자 제공과 위탁
서비스 운영 과정에서 Google 로그인, 외부 AI 모델, 음성 처리, 인프라 제공자 외부 서비스가 사용될 있다. 외부 AI 경로에는 원칙적으로 마스킹된 텍스트와 필요한 최소 메타데이터만 전달하고, prompt/completion 본문을 비용 감사 로그에 저장하지 않는다. 실제 위탁·제3자 제공 목록과 국외 이전 여부는 배포 별도 고지로 확정한다.
6. 안전성 확보조치
운영자는 서버 HttpOnly 쿠키 세션, 역할 기반 접근통제, 코호트 범위 제한, DB RLS, 감사 로그, 외부 AI 호출 메타데이터 기록, 개인정보 마스킹, 권한 회수, 비활성 계정 차단, 최소 권한 원칙을 적용한다. 운영 환경에서는 시크릿을 코드에 저장하지 않고, 접근 권한과 로그를 분리 관리한다.
7. 정보주체 권리
사용자는 본인의 개인정보 열람, 정정, 처리정지, 삭제 요청을 있다. 다만 교육 평가, 연구 검증, 법령상 보존 의무, 다른 사용자의 권리 보호, 감사 목적에 필요한 기록은 즉시 삭제가 제한될 있다. 요청 창구와 처리 절차는 운영자 한신대학교 담당 부서 확정 고지한다.
8. 미성년자와 보호자 동의
서비스가 미성년 학습자 또는 미성년 사례 자료를 다루는 경우 보호자 동의, 기관 승인, IRB 또는 이에 준하는 검토가 필요한지 별도로 확인한다. 현재 문안은 기술 구현용 초안이며 실제 운영 법무·개인정보·임상팀 검토가 필요하다.
9. 국내법 준수
개인정보 처리는 개인정보 보호법, 동법 시행령, 개인정보 처리방침 작성지침, 개인정보의 안전성 확보조치 기준 대한민국 개인정보보호 법령과 관련 고시를 기준으로 운영한다. 법령 개정 또는 기관 정책 변경 처리방침을 개정할 있다.
10. 시행과 개정
방침은 2026 6 27 개발 초안이다. 최종 시행일, 개인정보 보호책임자, 문의처, 보유 기간, 위탁·제3자 제공 내역은 운영 확정해 고지한다.
"""
class UserProfileResponse(BaseModel):
user_id: str
email: str
display_name: str
role: str
role: RoleName
cohort_ids: list[str]
affiliation: str
legal_name: str = ""
department: str = ""
grade_level: str = ""
phone: str = ""
contact_address: str = ""
nickname: str = ""
self_introduction: str = ""
avatar_url: str = ""
onboarding_completed_at: float | None = None
terms_agreed_at: float | None = None
privacy_agreed_at: float | None = None
terms_version: str = ""
privacy_version: str = ""
onboarding_required: bool = True
class UserProfilePatch(BaseModel):
display_name: str | None = Field(default=None, min_length=1, max_length=80)
affiliation: str | None = Field(default=None, max_length=120)
legal_name: str | None = Field(default=None, min_length=1, max_length=80)
department: str | None = Field(default=None, max_length=120)
grade_level: str | None = Field(default=None, max_length=40)
phone: str | None = Field(default=None, max_length=30)
contact_address: str | None = Field(default=None, max_length=300)
nickname: str | None = Field(default=None, min_length=1, max_length=40)
self_introduction: str | None = Field(default=None, max_length=600)
avatar_url: str | None = Field(default=None, max_length=500)
class OnboardingRequest(BaseModel):
legal_name: str = Field(..., min_length=1, max_length=80)
affiliation: str = Field(..., min_length=1, max_length=120)
department: str = Field(..., min_length=1, max_length=120)
grade_level: str = Field(..., min_length=1, max_length=40)
phone: str = Field(..., min_length=1, max_length=30)
contact_address: str = Field(..., min_length=1, max_length=300)
nickname: str = Field(..., min_length=1, max_length=40)
self_introduction: str = Field(..., min_length=1, max_length=600)
avatar_url: str = Field(default="", max_length=500)
terms_accepted: bool
privacy_accepted: bool
class AvatarUploadResponse(BaseModel):
avatar_url: str
content_type: str
size_bytes: int
class LegalDocument(BaseModel):
kind: Literal["terms", "privacy"]
version: str
title: str
body: str
status: Literal["draft"] = "draft"
class LegalDocumentsResponse(BaseModel):
terms: LegalDocument
privacy: LegalDocument
source_note: str
class NotificationPreferences(BaseModel):
@ -57,6 +200,34 @@ class VoicePresetResponse(BaseModel):
persona_hint: str
TicketCategory = Literal[
"account_access",
"session_review",
"voice_browser",
"content_scenario",
"safety",
"other",
]
TicketPriority = Literal["low", "normal", "high", "urgent"]
class UserSupportTicketRequest(BaseModel):
category: TicketCategory = "other"
priority: TicketPriority = "normal"
subject: str = Field(..., min_length=2, max_length=160)
body: str = Field(..., min_length=2, max_length=4000)
source_path: str = Field(default="", max_length=500)
class UserSupportTicketResponse(BaseModel):
ticket_id: str
status: Literal["open"]
category: TicketCategory
priority: TicketPriority
subject: str
created_at: float
_preferences: dict[str, UserPreferencesResponse] = {}
VOICE_PRESET_META = {
@ -135,6 +306,42 @@ def _preferences_from_row(row) -> UserPreferencesResponse:
)
def _onboarding_required(managed) -> bool:
return not (
managed
and managed.profile_completed_at is not None
and managed.terms_agreed_at is not None
and managed.privacy_agreed_at is not None
and bool(managed.nickname.strip())
and bool(managed.self_introduction.strip())
)
def _upload_root() -> Path:
root = Path(settings.user_upload_dir)
if not root.is_absolute():
root = Path.cwd() / root
avatar_root = root / "profile-avatars"
avatar_root.mkdir(parents=True, exist_ok=True)
return avatar_root
def _validated_avatar_extension(content_type: str, content: bytes) -> str:
normalized = content_type.split(";", 1)[0].strip().lower()
if normalized not in AVATAR_CONTENT_TYPES:
raise HTTPException(
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
detail="unsupported_avatar_type",
)
ext, magic = AVATAR_CONTENT_TYPES[normalized]
if normalized == "image/webp":
if not (content.startswith(magic) and content[8:12] == b"WEBP"):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
elif not content.startswith(magic):
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="invalid_avatar_file")
return ext
async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
managed = await get_managed_user(principal.user_id)
return UserProfileResponse(
@ -148,6 +355,42 @@ async def _profile_for(principal: CurrentPrincipal) -> UserProfileResponse:
role=(managed.role if managed else principal.role.value),
cohort_ids=(managed.cohort_ids if managed else principal.cohort_ids),
affiliation=(managed.affiliation if managed else DEFAULT_AFFILIATION),
legal_name=(managed.legal_name if managed else ""),
department=(managed.department if managed else ""),
grade_level=(managed.grade_level if managed else ""),
phone=(managed.phone if managed else ""),
contact_address=(managed.contact_address if managed else ""),
nickname=(managed.nickname if managed else ""),
self_introduction=(managed.self_introduction if managed else ""),
avatar_url=(managed.avatar_url if managed else ""),
onboarding_completed_at=(managed.profile_completed_at if managed else None),
terms_agreed_at=(managed.terms_agreed_at if managed else None),
privacy_agreed_at=(managed.privacy_agreed_at if managed else None),
terms_version=(managed.terms_version if managed else ""),
privacy_version=(managed.privacy_version if managed else ""),
onboarding_required=_onboarding_required(managed),
)
@router.get("/legal-docs", response_model=LegalDocumentsResponse)
async def get_legal_documents(principal: CurrentPrincipal) -> LegalDocumentsResponse:
return LegalDocumentsResponse(
terms=LegalDocument(
kind="terms",
version=TERMS_VERSION,
title="Vignette 서비스 이용약관 초안",
body=TERMS_BODY,
),
privacy=LegalDocument(
kind="privacy",
version=PRIVACY_VERSION,
title="Vignette 개인정보 수집·이용 및 처리방침 초안",
body=PRIVACY_BODY,
),
source_note=(
"법무 검토 전 개발 초안입니다. 개인정보 보호법 제30조, 개인정보 처리방침 작성지침, "
"개인정보의 안전성 확보조치 기준, 약관규제법 취지를 반영했습니다."
),
)
@ -159,14 +402,188 @@ async def get_me(principal: CurrentPrincipal) -> UserProfileResponse:
@router.patch("/me", response_model=UserProfileResponse)
async def patch_me(body: UserProfilePatch, principal: CurrentPrincipal) -> UserProfileResponse:
profile = await _profile_for(principal)
await update_managed_user(
updated = await update_managed_user(
principal.user_id,
display_name=body.display_name if body.display_name is not None else profile.display_name,
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
ManagedUserPatch(
display_name=body.display_name if body.display_name is not None else profile.display_name,
affiliation=body.affiliation if body.affiliation is not None else profile.affiliation,
legal_name=body.legal_name if body.legal_name is not None else profile.legal_name,
department=body.department if body.department is not None else profile.department,
grade_level=body.grade_level if body.grade_level is not None else profile.grade_level,
phone=body.phone if body.phone is not None else profile.phone,
contact_address=(
body.contact_address
if body.contact_address is not None
else profile.contact_address
),
nickname=body.nickname if body.nickname is not None else profile.nickname,
self_introduction=(
body.self_introduction
if body.self_introduction is not None
else profile.self_introduction
),
avatar_url=body.avatar_url if body.avatar_url is not None else profile.avatar_url,
),
)
if updated is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
return await _profile_for(principal)
@router.post("/me/avatar", response_model=AvatarUploadResponse)
async def upload_my_avatar(
principal: CurrentPrincipal,
file: UploadFile = File(...),
) -> AvatarUploadResponse:
content_type = (file.content_type or "").strip().lower()
content = await file.read(AVATAR_MAX_BYTES + 1)
await file.close()
if not content:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="empty_avatar_file")
if len(content) > AVATAR_MAX_BYTES:
raise HTTPException(status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, detail="avatar_too_large")
ext = _validated_avatar_extension(content_type, content)
root = _upload_root()
for existing in root.glob(f"{principal.user_id}-*.png"):
existing.unlink(missing_ok=True)
for existing in root.glob(f"{principal.user_id}-*.jpg"):
existing.unlink(missing_ok=True)
for existing in root.glob(f"{principal.user_id}-*.webp"):
existing.unlink(missing_ok=True)
filename = f"{principal.user_id}-{secrets.token_urlsafe(10)}.{ext}"
target = root / filename
target.write_bytes(content)
avatar_url = f"/uploads/profile-avatars/{filename}"
return AvatarUploadResponse(
avatar_url=avatar_url,
content_type=content_type.split(";", 1)[0],
size_bytes=len(content),
)
@router.post("/me/onboarding", response_model=UserProfileResponse)
async def complete_onboarding(
body: OnboardingRequest,
principal: CurrentPrincipal,
) -> UserProfileResponse:
if not body.terms_accepted:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="terms_not_accepted")
if not body.privacy_accepted:
raise HTTPException(status.HTTP_400_BAD_REQUEST, detail="privacy_not_accepted")
display_name = body.nickname.strip()
updated = await update_managed_user(
principal.user_id,
ManagedUserPatch(
display_name=display_name,
affiliation=body.affiliation,
legal_name=body.legal_name,
department=body.department,
grade_level=body.grade_level,
phone=body.phone,
contact_address=body.contact_address,
nickname=body.nickname,
self_introduction=body.self_introduction,
avatar_url=body.avatar_url,
complete_onboarding=True,
terms_version=TERMS_VERSION,
privacy_version=PRIVACY_VERSION,
),
)
if updated is None:
raise HTTPException(status.HTTP_404_NOT_FOUND, detail="user not found")
principal.display_name = updated.display_name
principal.profile_completed_at = updated.profile_completed_at
if (principal.role == Role.LEARNER or principal.super_admin) and principal.consent_at is None:
principal.consent_at = await record_user_consent(principal.user_id)
return await _profile_for(principal)
@router.post(
"/support-tickets",
response_model=UserSupportTicketResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_support_ticket(
body: UserSupportTicketRequest,
principal: CurrentPrincipal,
) -> UserSupportTicketResponse:
profile = await _profile_for(principal)
try:
async with acquire(role=principal.role.value, user_id=principal.user_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.support_ticket (
reporter_id,
reporter_email,
reporter_name,
reporter_role,
category,
priority,
subject,
body,
source_path
)
VALUES (
$1::uuid,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
RETURNING id, category, priority, subject, EXTRACT(EPOCH FROM created_at) AS created_at
""",
principal.user_id,
principal.email,
profile.display_name,
principal.role.value,
body.category,
body.priority,
body.subject.strip(),
body.body.strip(),
body.source_path.strip(),
)
await conn.execute(
"""
INSERT INTO audit.audit_log (
actor_uid, action, target_kind, target_id, detail
)
VALUES ($1::uuid, $2, $3, $4, $5::jsonb)
""",
principal.user_id,
"support_ticket_create",
"support_ticket",
str(row["id"]),
{
"category": row["category"],
"priority": row["priority"],
"source_path": body.source_path.strip(),
"subject_present": bool(body.subject.strip()),
"body_present": bool(body.body.strip()),
},
)
except Exception as exc:
raise HTTPException(
status.HTTP_503_SERVICE_UNAVAILABLE,
detail="support ticket persistence unavailable",
) from exc
return UserSupportTicketResponse(
ticket_id=str(row["id"]),
status="open",
category=row["category"],
priority=row["priority"],
subject=row["subject"],
created_at=float(row["created_at"] or 0.0),
)
@router.get("/me/preferences", response_model=UserPreferencesResponse)
async def get_preferences(principal: CurrentPrincipal) -> UserPreferencesResponse:
try:

View file

@ -5,7 +5,7 @@ Client sends JSON controls plus binary audio chunks:
Server emits:
ready -> state(listening) -> state(thinking) -> transcript -> reply
-> state(speaking) -> tts_chunk + binary audio chunks -> tts_end -> state(idle)
-> state(speaking) -> binary audio chunks -> tts_end -> state(idle)
When voice is not configured, the route reports degraded state and closes
cleanly instead of crashing.
@ -23,11 +23,16 @@ from fastapi.responses import JSONResponse
from starlette.websockets import WebSocketState
from .. import session_persistence, turn_runtime
from ..auth_sessions import get_session, user_has_consent
from ..auth_sessions import get_session, user_has_consent, user_onboarding_complete
from ..config import settings
from ..deps import Principal, Role
from ..engine_client import EngineError, engine_client
from ..persona_repository import get_catalog_persona
from ..persona_repository import (
PersonaVoiceMap,
get_catalog_persona,
get_persona_voice_map,
get_session_voice_map,
)
from ..runtime_policy import require_runtime_fallback_allowed
from ..services import evaluator, orchestrator, state_machine
from ..services import voice as voice_svc
@ -72,6 +77,8 @@ async def voice_ws(websocket: WebSocket) -> None:
await _safe_send_json(websocket, {"type": "error", "detail": "not authenticated"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
return
if principal.role != Role.LEARNER and principal.super_admin:
principal = principal.with_role(Role.LEARNER)
if principal.role != Role.LEARNER:
await _safe_send_json(websocket, {"type": "error", "detail": "only learners can use voice"})
await _safe_close(websocket, WS_CLOSE_UNAUTHORIZED)
@ -418,6 +425,8 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
session = await get_session(raw_cookie)
if session is None:
return None
if session.account_status != "approved":
return None
try:
role = Role(session.role)
@ -427,10 +436,14 @@ async def _principal_from_websocket(websocket: WebSocket) -> Principal | None:
return Principal(
user_id=session.user_id,
role=role,
admin_access=session.admin_access,
super_admin=session.super_admin,
account_status=session.account_status,
cohort_ids=session.cohort_ids,
email=session.email,
display_name=session.display_name,
consent_at=session.consent_at,
profile_completed_at=session.profile_completed_at,
)
@ -447,7 +460,11 @@ async def _bind_session(
sess, err = await _load_voice_session(session_id, principal)
if sess is None:
return None, None, err or f"unknown session {session_id}", {}
vp = resolve_voice(persona_code=sess.persona.code, preset=explicit_preset)
vp = await _resolve_session_voice(
session_id=session_id,
persona_code=sess.persona.code,
explicit_preset=explicit_preset,
)
return session_id, vp, None, {"degraded": False, "persona_catalog_source": "session"}
# persona_code session creation is local-dev only. Production uses REST start.
@ -457,6 +474,11 @@ async def _bind_session(
persona_code = qp.get("persona_code")
if not persona_code:
return None, None, "session_id or persona_code query required", {}
if (
principal.profile_completed_at is None
and not await user_onboarding_complete(principal.user_id)
):
return None, None, "onboarding_required", {}
if principal.consent_at is None and not await user_has_consent(principal.user_id):
return None, None, "consent_required", {}
try:
@ -494,7 +516,12 @@ async def _bind_session(
session_source = "runtime"
else:
store.put(sess)
vp = resolve_voice(persona_code=card.code, preset=explicit_preset)
vp = await _resolve_catalog_voice(
persona_id=catalog_persona.persona_id,
version=catalog_persona.version,
persona_code=card.code,
explicit_preset=explicit_preset,
)
degraded_reasons: list[str] = []
if catalog_persona.degraded:
degraded_reasons.append("카탈로그 원본을 확인하지 못해 음성 회기를 시작하지 않습니다")
@ -509,6 +536,54 @@ async def _bind_session(
return sess.session_id, vp, None, bind_meta
async def _resolve_session_voice(
*,
session_id: str,
persona_code: str,
explicit_preset: str | None,
) -> VoicePreset:
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
if explicit_preset:
return fallback
try:
voice_map = await get_session_voice_map(session_id)
except Exception:
return fallback
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
async def _resolve_catalog_voice(
*,
persona_id: str | None,
version: int | None,
persona_code: str,
explicit_preset: str | None,
) -> VoicePreset:
fallback = resolve_voice(persona_code=persona_code, preset=explicit_preset)
if explicit_preset:
return fallback
try:
voice_map = await get_persona_voice_map(persona_id=persona_id, version=version)
except Exception:
return fallback
return _voice_from_map(voice_map, persona_code=persona_code) or fallback
def _voice_from_map(
voice_map: PersonaVoiceMap | None,
*,
persona_code: str,
) -> VoicePreset | None:
if voice_map is None:
return None
return voice_svc.resolve_voice_from_map(
provider=voice_map.provider,
voice_id=voice_map.voice_id,
base_params=voice_map.base_params,
persona_code=persona_code,
)
def _audio_meta(fmt: Optional[str]) -> tuple[str, str]:
"""Map the browser audio format to upload metadata."""
f = (fmt or "webm").lower().lstrip(".")

View file

@ -0,0 +1,677 @@
"""라이브 코칭 AI — 턴 직후 짧은 슈퍼비전 힌트.
내담자 생성 루프와 분리된 별도 경로다. 상담 응답 스트리밍은 막지 않고,
턴이 저장된 학습자 UI가 서비스를 호출해 다음 문장 중심의 코칭을 받는다.
원칙:
- 상담 루프 비차단: 엔진/RAG 실패 규칙 기반 코칭으로 degrade.
- PII 마스킹 외부 LLM 전송.
- 허가된 DSM/공식 지침/논문 요약 KB를 근거로 사용하되, 진단 확정·처방·장문 원문 재현은 금지.
- 학습자에게 페르소나 내부 정답(CCD/상태 수치) 노출하지 않는다.
"""
from __future__ import annotations
import json
import time
import hashlib
from functools import lru_cache
from typing import TYPE_CHECKING, Any, Literal, Optional
from pydantic import BaseModel, Field
from ..config import settings
from ..engine_client import EngineClient, EngineError, EngineMessage, GenerateRequest, GenerateResponse
from ..paths import repo_root, repo_path
from . import guardrail
if TYPE_CHECKING:
from .orchestrator import LlmAuditHook
Tone = Literal["pos", "warn", "neutral"]
CoachStatus = Literal["ready", "degraded"]
CoachFocus = Literal[
"rapport",
"exploration",
"risk",
"emotion",
"cognition",
"behavior",
"interpersonal",
"goal",
"pacing",
]
class LiveCoachSource(BaseModel):
"""라이브 코칭 근거 출처. 원문 장문이 아니라 출처 식별자와 위치만 노출한다."""
source_id: str
title: str
locator: Optional[str] = None
kb_kind: str = "template"
source_type: Optional[str] = None
version: Optional[str] = None
citation: Optional[str] = None
class LiveCoachSuggestion(BaseModel):
"""프론트가 그대로 표시하는 턴 직후 코칭 카드."""
status: CoachStatus = "ready"
tone: Tone = "neutral"
focus: CoachFocus = "exploration"
title: str
message: str
next_utterance: Optional[str] = None
rationale: Optional[str] = None
sources: list[LiveCoachSource] = Field(default_factory=list)
safety_note: Optional[str] = None
latency_ms: int = 0
class LiveCoachEvent(BaseModel):
"""회기 중 실제로 전달된 라이브 코칭 이력."""
event_id: str
session_id: str
turn_seq: int
stage: str
created_at: str
learner_text_excerpt: Optional[str] = None
client_reply_excerpt: Optional[str] = None
suggestion: LiveCoachSuggestion
class LiveCoachInput(BaseModel):
"""라이브 코칭 입력. raw text는 서비스 내부에서 마스킹 후 프롬프트에 쓴다."""
session_id: str
turn_seq: int
stage: str
effective_openness: float
theory_mode: str
persona_code: str
persona_name: str
learner_text: str
client_reply: Optional[str] = None
recent_turns: list[dict[str, str]] = Field(default_factory=list)
evaluation: Optional[dict[str, Any]] = None
class LiveCoachGrounding(BaseModel):
"""LLM에 넣는 짧은 근거 요약. 원문을 길게 복사하지 않는다."""
source_id: str
title: str
locator: Optional[str] = None
kb_kind: str = "template"
source_type: Optional[str] = None
version: Optional[str] = None
citation: Optional[str] = None
summary: str
_REPO_ROOT = repo_root()
_WORKBOOK_PATH = repo_path("data", "kb", "live_coaching_workbook_0615.json")
_SOURCE_DIR = repo_path("data", "kb", "live_coaching_sources")
_ALLOWED_KB_KINDS = {
"diagnostic",
"theory",
"technique",
"taxonomy",
"supervisor_pattern",
"template",
"ko_context",
"microskill",
}
def _configured_model(value: str | None) -> str | None:
model = (value or "").strip()
return model or None
@lru_cache(maxsize=1)
def _local_source_entries() -> tuple[tuple[str, dict[str, Any]], ...]:
entries: list[tuple[str, dict[str, Any]]] = []
paths = [_WORKBOOK_PATH]
if _SOURCE_DIR.exists():
paths.extend(sorted(_SOURCE_DIR.glob("*.json")))
for path in paths:
try:
payload = json.loads(path.read_text(encoding="utf-8"))
except Exception:
continue
if isinstance(payload, dict):
entries.append((str(path.relative_to(_REPO_ROOT)).replace("\\", "/"), payload))
if entries:
return tuple(entries)
return (
(
str(_WORKBOOK_PATH.relative_to(_REPO_ROOT)).replace("\\", "/"),
{
"source": {
"source_id": "workbook_0615_case_conceptualization",
"title": "0615 사례개념화 워크북",
"external_llm_ok": True,
"kb_kind": "template",
},
"chunks": [],
},
),
)
def _local_source_payloads() -> list[dict[str, Any]]:
return [payload for _, payload in _local_source_entries()]
def iter_local_source_packs() -> list[dict[str, Any]]:
"""허가된 라이브 코칭 source pack 목록을 반환한다."""
return _local_source_payloads()
def _source_id(source: dict[str, Any]) -> str:
return str(source.get("source_id") or "").strip()
def _source_kb_kind(source: dict[str, Any], chunks: list[dict[str, Any]]) -> str:
value = str(source.get("kb_kind") or "").strip()
if value in _ALLOWED_KB_KINDS:
return value
for chunk in chunks:
value = str(chunk.get("kb_kind") or "").strip()
if value in _ALLOWED_KB_KINDS:
return value
return "supervisor_pattern"
def _license_class(source: dict[str, Any]) -> str:
value = str(source.get("license_class") or "B").strip().upper()
return value if value in {"A", "B", "C", "D"} else "B"
def _rag_visible_to(source: dict[str, Any], chunk: dict[str, Any]) -> list[str]:
configured = chunk.get("visible_to") or source.get("visible_to")
if isinstance(configured, list):
values = [str(item).strip() for item in configured if str(item).strip()]
if values:
return values
return ["evaluator"]
def _rag_sensitivity(source: dict[str, Any], chunk: dict[str, Any], kb_kind: str) -> int:
configured = chunk.get("sensitivity", source.get("sensitivity"))
if configured is not None:
try:
return max(0, min(3, int(configured)))
except (TypeError, ValueError):
pass
if _license_class(source) in {"C", "D"} or kb_kind in {"diagnostic", "taxonomy"}:
return 2
return 1
def _canonical_hash(payload: dict[str, Any]) -> str:
raw = json.dumps(payload, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
def _rag_version(source: dict[str, Any]) -> int:
value = source.get("rag_version", source.get("index_version", 1))
try:
return max(1, int(value))
except (TypeError, ValueError):
return 1
def build_rag_source_rows() -> list[dict[str, Any]]:
"""라이브 코칭 source pack을 kb.source upsert row로 변환한다."""
rows: list[dict[str, Any]] = []
for origin_path, payload in _local_source_entries():
source = payload.get("source") or {}
chunks = [chunk for chunk in (payload.get("chunks") or []) if isinstance(chunk, dict)]
source_id = _source_id(source)
if not source_id:
continue
rows.append(
{
"source_id": source_id,
"title": str(source.get("title") or source_id),
"kb_kind": _source_kb_kind(source, chunks),
"license_class": _license_class(source),
"origin_path": origin_path,
"citation": str(source.get("citation") or ""),
"external_llm_ok": bool(source.get("external_llm_ok", True)),
}
)
return rows
def build_rag_index_payloads() -> list[dict[str, Any]]:
"""라이브 코칭 source pack을 /kb/index 요청 payload로 변환한다."""
payloads: list[dict[str, Any]] = []
for origin_path, payload in _local_source_entries():
source = payload.get("source") or {}
source_id = _source_id(source)
if not source_id:
continue
title = str(source.get("title") or source_id)
citation = str(source.get("citation") or "")
version_label = str(source.get("version") or "")
chunks_in = [chunk for chunk in (payload.get("chunks") or []) if isinstance(chunk, dict)]
chunks: list[dict[str, Any]] = []
for seq, chunk in enumerate(chunks_in):
summary = str(chunk.get("summary") or "").strip()
if not summary:
continue
kb_kind = str(chunk.get("kb_kind") or _source_kb_kind(source, chunks_in))
if kb_kind not in _ALLOWED_KB_KINDS:
kb_kind = "supervisor_pattern"
heading = str(chunk.get("heading") or chunk.get("id") or f"chunk-{seq}")
chunk_citation = str(chunk.get("citation") or citation)
context = f"{title} / {heading}"
if version_label:
context += f" / {version_label}"
if chunk_citation:
context += f" / {chunk_citation}"
chunks.append(
{
"seq": seq,
"chunk_text": summary,
"heading_path": heading,
"context_prefix": context,
"kb_kind": kb_kind,
"visible_to": _rag_visible_to(source, chunk),
"sensitivity": _rag_sensitivity(source, chunk, kb_kind),
"meta": {
"live_coaching_source": True,
"source_type": str(chunk.get("source_type") or source.get("source_type") or ""),
"source_version": version_label,
"citation": chunk_citation,
"license_class": _license_class(source),
"external_llm_ok": bool(source.get("external_llm_ok", True)),
"keywords": chunk.get("keywords") or [],
},
"token_count": max(1, len(summary) // 4),
}
)
if not chunks:
continue
payloads.append(
{
"source_id": source_id,
"doc_uri": f"live-coaching/{origin_path}",
"version": _rag_version(source),
"content_hash": _canonical_hash({"source": source, "chunks": chunks_in}),
"chunks": chunks,
}
)
return payloads
def _local_reference_grounding(item: LiveCoachInput, *, limit: int = 5) -> list[LiveCoachGrounding]:
query = " ".join(
[
item.stage,
item.theory_mode,
item.learner_text,
item.client_reply or "",
" ".join(str((item.evaluation or {}).get(k, "")) for k in ("appropriateness", "appropriateness_note")),
]
).lower()
scored: list[tuple[int, int, dict[str, Any], dict[str, Any]]] = []
fallback: list[tuple[int, dict[str, Any], dict[str, Any]]] = []
for source_index, payload in enumerate(_local_source_payloads()):
source = payload.get("source") or {}
if source.get("external_llm_ok") is False:
continue
for chunk_index, chunk in enumerate(payload.get("chunks") or []):
if not isinstance(chunk, dict):
continue
fallback.append((source_index * 1000 + chunk_index, source, chunk))
keywords = [str(k).lower() for k in (chunk.get("keywords") or [])]
score = sum(1 for kw in keywords if kw and kw in query)
if score > 0:
priority = int(source.get("priority") or 50)
scored.append((score, priority, source, chunk))
if not scored:
scored = [(1, -order, source, chunk) for order, source, chunk in fallback[:limit]]
scored.sort(key=lambda pair: (pair[0], pair[1]), reverse=True)
out: list[LiveCoachGrounding] = []
for _, _, source, chunk in scored[:limit]:
out.append(
LiveCoachGrounding(
source_id=str(chunk.get("source_id") or source.get("source_id") or "live_coaching_source"),
title=str(chunk.get("title") or source.get("title") or "라이브 코칭 KB"),
locator=str(chunk.get("heading") or chunk.get("id") or "") or None,
kb_kind=str(chunk.get("kb_kind") or "template"),
source_type=str(chunk.get("source_type") or source.get("source_type") or "") or None,
version=str(chunk.get("version") or source.get("version") or "") or None,
citation=str(chunk.get("citation") or source.get("citation") or "") or None,
summary=str(chunk.get("summary") or ""),
)
)
return out
def _source_refs(grounding: list[LiveCoachGrounding]) -> list[LiveCoachSource]:
seen: set[tuple[str, str | None]] = set()
refs: list[LiveCoachSource] = []
for g in grounding:
key = (g.source_id, g.locator)
if key in seen:
continue
seen.add(key)
refs.append(
LiveCoachSource(
source_id=g.source_id,
title=g.title,
locator=g.locator,
kb_kind=g.kb_kind,
source_type=g.source_type,
version=g.version,
citation=g.citation,
)
)
return refs[:4]
def _schema() -> dict[str, Any]:
return {
"type": "object",
"additionalProperties": False,
"properties": {
"tone": {"type": "string", "enum": ["pos", "warn", "neutral"]},
"focus": {
"type": "string",
"enum": [
"rapport",
"exploration",
"risk",
"emotion",
"cognition",
"behavior",
"interpersonal",
"goal",
"pacing",
],
},
"title": {"type": "string"},
"message": {"type": "string"},
"next_utterance": {"type": ["string", "null"]},
"rationale": {"type": ["string", "null"]},
"safety_note": {"type": ["string", "null"]},
},
"required": ["tone", "focus", "title", "message"],
}
def _structured_payload(resp: GenerateResponse) -> Optional[dict[str, Any]]:
if isinstance(resp.structured, dict):
return resp.structured
raw = (resp.text or "").strip()
if not raw:
return None
if raw.startswith("```"):
raw = raw.split("```", 2)[1] if raw.count("```") >= 2 else raw.strip("`")
if raw.lstrip().lower().startswith("json"):
raw = raw.lstrip()[4:]
try:
data = json.loads(raw)
return data if isinstance(data, dict) else None
except (json.JSONDecodeError, ValueError):
start, end = raw.find("{"), raw.rfind("}")
if 0 <= start < end:
try:
data = json.loads(raw[start : end + 1])
return data if isinstance(data, dict) else None
except (json.JSONDecodeError, ValueError):
return None
return None
def _clip(value: Any, limit: int) -> Optional[str]:
text = str(value or "").strip()
if not text:
return None
return text if len(text) <= limit else text[: limit - 1].rstrip() + ""
def _coerce_payload(payload: dict[str, Any], *, sources: list[LiveCoachSource], latency_ms: int) -> LiveCoachSuggestion:
tone = str(payload.get("tone") or "neutral")
if tone not in ("pos", "warn", "neutral"):
tone = "neutral"
focus = str(payload.get("focus") or "exploration")
allowed_focus = {
"rapport",
"exploration",
"risk",
"emotion",
"cognition",
"behavior",
"interpersonal",
"goal",
"pacing",
}
if focus not in allowed_focus:
focus = "exploration"
return LiveCoachSuggestion(
status="ready",
tone=tone, # type: ignore[arg-type]
focus=focus, # type: ignore[arg-type]
title=_clip(payload.get("title"), 32) or "다음 발화 조정",
message=_clip(payload.get("message"), 120) or "지금은 내담자 말을 더 구체적으로 따라가는 편이 낫다.",
next_utterance=_clip(payload.get("next_utterance"), 140),
rationale=_clip(payload.get("rationale"), 180),
safety_note=_clip(payload.get("safety_note"), 120),
sources=sources,
latency_ms=latency_ms,
)
def _evaluation_tone(evaluation: Optional[dict[str, Any]]) -> Tone:
if not evaluation:
return "neutral"
value = str(evaluation.get("appropriateness") or "neutral")
if value == "pos":
return "pos"
if value == "warn":
return "warn"
return "neutral"
def _fallback_suggestion(
item: LiveCoachInput,
*,
grounding: list[LiveCoachGrounding],
status: CoachStatus = "degraded",
reason: Optional[str] = None,
) -> LiveCoachSuggestion:
text = item.learner_text
low_open = item.effective_openness < 0.35
tone: Tone = _evaluation_tone(item.evaluation)
focus: CoachFocus = "exploration"
title = "다음 탐색"
message = "내담자 표현을 한 번 반영한 뒤, 방금 말한 장면을 더 구체적으로 물어봐라."
next_line = "방금 말한 그 장면이 언제부터 특히 힘들게 느껴졌는지 조금만 더 들려줄래요?"
crisis = guardrail.classify_crisis(text)
if crisis.kind != guardrail.CrisisKind.NONE:
tone = "warn"
focus = "risk"
title = "안전 먼저"
message = "위험 단서가 나온 턴이다. 방법을 캐묻지 말고 최근성, 강도, 보호요인을 차분히 확인해라."
next_line = "그 생각이 최근에 얼마나 자주, 얼마나 강하게 올라오는지 안전을 위해 같이 확인해도 될까요?"
elif any(word in text for word in ("해야", "해봐", "괜찮아", "그냥", "왜 안")):
tone = "warn"
focus = "rapport"
title = "조언 속도 낮추기"
message = "지금은 해결책보다 감정과 욕구 반영이 먼저다. 설득처럼 들릴 수 있는 표현을 줄여라."
next_line = "그만큼 답답하고 막막해서 쉽게 움직이기 어려운 마음이 있는 것 같아요."
elif any(word in text for word in ("느꼈", "마음", "감정", "속상", "힘들")):
tone = "pos" if tone != "warn" else tone
focus = "emotion"
title = "감정 반영 유지"
message = "감정으로 잘 들어갔다. 다음에는 그 감정 밑의 욕구나 구체 사건을 한 단계만 더 확인해라."
next_line = "그 마음이 가장 크게 올라왔던 순간이 언제였는지 떠오르는 장면이 있을까요?"
elif low_open:
focus = "pacing"
title = "짧게, 선택권 있게"
message = "아직 개방도가 낮다. 질문을 좁히고, 내담자가 답하지 않을 권리도 함께 줘라."
next_line = "대답하기 불편하면 넘어가도 괜찮아요. 그래도 지금 제일 덜 부담되는 얘기부터 해볼까요?"
if reason:
rationale = f"AI 코칭 엔진은 {reason}. 현재 코칭은 워크북 루브릭과 규칙 기반 신호로 생성했다."
else:
rationale = "워크북의 첫 회기 사례개념화 틀과 현재 턴 신호를 기준으로 한 비차단 코칭이다."
return LiveCoachSuggestion(
status=status,
tone=tone,
focus=focus,
title=title,
message=message,
next_utterance=next_line,
rationale=rationale,
sources=_source_refs(grounding),
latency_ms=0,
)
def _grounding_block(grounding: list[LiveCoachGrounding]) -> str:
if not grounding:
return "(근거 없음)"
lines: list[str] = []
for index, item in enumerate(grounding[:6], start=1):
locator = f" / {item.locator}" if item.locator else ""
version = f" / {item.version}" if item.version else ""
citation = f"\n- 출처: {item.citation[:220]}" if item.citation else ""
lines.append(
f"[{index}] {item.source_id}{locator}{version} ({item.kb_kind})\n"
f"- {item.summary[:360]}{citation}"
)
return "\n".join(lines)
def _messages(item: LiveCoachInput, grounding: list[LiveCoachGrounding]) -> list[EngineMessage]:
learner_masked = guardrail.mask_pii(item.learner_text).text_masked
client_masked = guardrail.mask_pii(item.client_reply or "").text_masked
recent = "\n".join(
f"{'상담자' if t.get('speaker') == 'counselor' else '내담자'}: {t.get('text', '')}"
for t in item.recent_turns[-6:]
) or "(최근 맥락 없음)"
evaluation = json.dumps(item.evaluation or {}, ensure_ascii=False)[:1200]
system = (
"당신은 심리상담 수련생에게 회기 중 즉시 피드백을 주는 라이브 코치다.\n"
"목표는 지금 흐름을 끊지 않고 다음 상담자 발화 하나를 더 낫게 만드는 것이다.\n\n"
"[절대 규칙]\n"
"- 점수, 등급, 정답 공개, 페르소나 내부 설정(CCD/DSM 차원/상태 수치) 노출 금지.\n"
"- 허가된 DSM/공식 지침/논문 요약 KB는 근거로 사용할 수 있다.\n"
"- 그래도 진단 확정, 처방, 공식 문항·DSM 원문 장문 재현은 금지한다. 근거는 짧게 요약하고 출처 식별자만 남긴다.\n"
"- 위기 단서가 있으면 코칭보다 안전 확인, 보호요인, 109/기관 연결 방향을 우선한다.\n"
"- 메시지는 한국어 반말이 아니라 학습자 UI 문장체로 간결하게 쓴다.\n"
"- next_utterance는 상담자가 바로 말할 수 있는 한 문장만 제시한다.\n\n"
"[근거 기반 코칭 프레임]\n"
"첫 회기에서는 내담자 언어의 호소를 신체/생리, 인지, 정서, 대처행동, 대인관계로 나누고, "
"촉발사건과 가족/학교/또래 상호작용을 단정 없이 탐색한다. 감정은 먼저 타당화하고, "
"위험 단서는 방법을 캐묻지 않은 채 안전 확인으로 다룬다. 목표와 전략은 생물/심리/사회 "
"영역의 구체 행동으로 연결한다. DSM/지침 근거는 상담자 판단을 정렬하는 내부 참조이며, "
"학습자에게는 관찰 가능한 상담 행동과 다음 발화로만 번역한다."
)
user = (
f"[세션] {item.session_id} / turn {item.turn_seq}\n"
f"[내담자] {item.persona_name} ({item.persona_code})\n"
f"[단계] {item.stage} / openness {item.effective_openness:.2f} / 이론 {item.theory_mode}\n\n"
f"[최근 맥락]\n{recent}\n\n"
f"[이번 상담자 발화]\n{learner_masked}\n\n"
f"[이어진 내담자 응답]\n{client_masked or '(아직 없음)'}\n\n"
f"[fast-loop 평가 신호]\n{evaluation}\n\n"
f"[검색/워크북 근거]\n{_grounding_block(grounding)}\n\n"
"출력은 structured schema에 맞춰라. title은 16자 안팎, message는 120자 이내, "
"next_utterance는 한 문장으로."
)
return [
EngineMessage(role="system", content=system, cache=True),
EngineMessage(role="user", content=user, cache=False),
]
async def _record_llm_audit(
audit_hook: Optional["LlmAuditHook"],
**payload: Any,
) -> None:
if audit_hook is None:
return
try:
await audit_hook(payload)
except Exception:
return
async def generate_live_coaching(
item: LiveCoachInput,
*,
engine: EngineClient,
grounding: Optional[list[LiveCoachGrounding]] = None,
audit_hook: Optional["LlmAuditHook"] = None,
) -> LiveCoachSuggestion:
"""턴 직후 라이브 코칭을 생성한다. 실패해도 규칙 기반 제안으로 반환한다."""
local_grounding = _local_reference_grounding(item)
all_grounding = [*local_grounding, *(grounding or [])]
crisis = guardrail.classify_crisis(item.learner_text)
if crisis.escalate:
return _fallback_suggestion(item, grounding=all_grounding, status="ready")
started = time.perf_counter()
try:
req = GenerateRequest(
ai_role="evaluator",
messages=_messages(item, all_grounding),
structured_schema=_schema(),
model=_configured_model(settings.evaluator_fast_model),
max_tokens=700,
temperature=0.2,
session_id=item.session_id,
metadata={"loop": "live_coach", "turn_seq": item.turn_seq, "stage": item.stage},
)
resp = await engine.generate(req)
latency_ms = int((time.perf_counter() - started) * 1000)
await _record_llm_audit(
audit_hook,
session_id=item.session_id,
provider=resp.provider,
model=resp.model,
tokens_in=resp.tokens_in,
tokens_out=resp.tokens_out,
cost_usd=resp.cost_usd,
inference_geo=resp.inference_geo,
latency_ms=latency_ms,
)
payload = _structured_payload(resp)
if payload is None:
return _fallback_suggestion(
item,
grounding=all_grounding,
reason="구조화 출력을 반환하지 않았다",
)
return _coerce_payload(payload, sources=_source_refs(all_grounding), latency_ms=latency_ms)
except EngineError as exc:
return _fallback_suggestion(item, grounding=all_grounding, reason=str(exc))
except Exception as exc:
return _fallback_suggestion(item, grounding=all_grounding, reason=str(exc))
__all__ = [
"LiveCoachEvent",
"LiveCoachGrounding",
"LiveCoachInput",
"LiveCoachSource",
"LiveCoachSuggestion",
"generate_live_coaching",
]

View file

@ -252,6 +252,7 @@ def _vector_literal(vec: Sequence[float]) -> str:
# $7 = w_sparse(real)
# $8 = pre_k (int — dense/sparse 각 후보 수, 보통 50)
# $9 = k (int — 융합 후 반환 수)
# $10 = source_ids(text[] — 빈 배열이면 전체 허용)
#
# 정보비대칭 강제: 두 CTE 모두 `$4 = ANY(visible_to) AND sensitivity <= $5` 사전필터.
# kinds 빈 배열 처리: cardinality($3)=0 이면 kb_kind 조건을 통과(전체).
@ -267,6 +268,7 @@ dense AS (
FROM kb.chunk c, params p
WHERE c.embedding IS NOT NULL
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
AND $4 = ANY(c.visible_to)
AND c.sensitivity <= $5
ORDER BY c.embedding <=> p.q_dense
@ -279,6 +281,7 @@ sparse AS (
WHERE p.q_ts IS NOT NULL
AND to_tsvector('simple', c.chunk_text) @@ p.q_ts
AND (cardinality($3::text[]) = 0 OR c.kb_kind = ANY($3::text[]))
AND (cardinality($10::text[]) = 0 OR c.source_id = ANY($10::text[]))
AND $4 = ANY(c.visible_to)
AND c.sensitivity <= $5
ORDER BY s_sparse DESC
@ -473,6 +476,7 @@ async def search_kb(
fs = filters.get("sensitivity_max")
if isinstance(fs, int):
sens_max = min(sens_max, fs) # 더 엄격하게만
source_filter = [str(item) for item in (filters.get("source_id", []) if filters else [])]
# (2) 질의 임베딩(dense+sparse). 모델 미가용 → NotConfigured 전파.
eq = await asyncio.to_thread(embed_query, query) # CPU 인코딩 → 스레드풀(이벤트루프 비차단)
@ -491,17 +495,13 @@ async def search_kb(
policy.w_sparse, # $7
pre_k, # $8 pre_k
max(k * 4, k), # $9 융합 후 1차 컷(리랭킹 입력 여유분)
source_filter, # $10 source_id 좁힘
)
except Exception as e: # UndefinedFunction(vector 미설치) / UndefinedColumn 등
raise NotConfigured(f"KB hybrid query failed (DB/pgvector not ready): {e}") from e
# source_id 추가 좁힘(SQL 후처리 — 화이트리스트 보존, 코드 단순화)
src_filter = set(filters.get("source_id", [])) if filters else set()
chunks: list[RetrievedChunk] = []
for r in rows:
if src_filter and r["source_id"] not in src_filter:
continue
# asyncpg는 jsonb를 str(JSON text)로 반환 → 파싱. 코덱 등록 시 dict 그대로도 수용.
_meta_raw = r["meta"]
meta = json.loads(_meta_raw) if isinstance(_meta_raw, str) else dict(_meta_raw or {})
@ -635,6 +635,7 @@ async def retrieve_eval_grounding(
query: str,
k: int = 5,
kinds: Optional[Sequence[str]] = None,
source_ids: Optional[Sequence[str]] = None,
rerank: bool = True,
) -> RetrievalResult:
"""평가 AI 채점 근거 회수 — DSM/이론/taxonomy 정답라벨 + 논평.
@ -644,13 +645,17 @@ async def retrieve_eval_grounding(
kinds: 평가 차원에 따라 좁히기(: 기법 채점 ['technique','supervisor_pattern']).
"""
filters = {"kb_kind": list(kinds)} if kinds else None
filters: dict[str, Any] = {}
if kinds:
filters["kb_kind"] = list(kinds)
if source_ids:
filters["source_id"] = list(source_ids)
return await search_kb(
conn,
query=query,
role=AIRole.EVALUATOR,
k=k,
filters=filters,
filters=filters or None,
rerank=rerank,
)

View file

@ -0,0 +1,247 @@
"""Session-level learning metrics shared by learner and teacher dashboards."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from typing import Any, Callable
from ..store import InProcSession
_APPROPRIATENESS_SCORE = {
"neg": 0.0,
"warn": 0.25,
"neutral": 0.5,
"pos": 1.0,
}
@dataclass(frozen=True)
class SessionGrowthPoint:
session_id: str
session_no: int
persona_code: str
stage: str
started_at: str
ended_at: str | None
score: float | None = None
rapport: float | None = None
technique_count: int = 0
watch_count: int = 0
@dataclass(frozen=True)
class LearnerGrowthMetrics:
learner_id: str
learner_label: str
sessions: int
ended_sessions: int
latest_at: str
first_score: float | None = None
latest_score: float | None = None
score_delta: float | None = None
avg_score: float | None = None
avg_rapport: float | None = None
trend: str = "insufficient"
top_techniques: list[str] = field(default_factory=list)
points: list[SessionGrowthPoint] = field(default_factory=list)
def iso_datetime(ts: float | None) -> str | None:
if ts is None:
return None
return datetime.fromtimestamp(ts).isoformat(timespec="seconds")
def session_activity_time(sess: InProcSession) -> float:
return sess.ended_at or sess.created_at
def safe_float(value: object) -> float | None:
try:
return float(value) # type: ignore[arg-type]
except (TypeError, ValueError):
return None
def avg(values: list[float]) -> float | None:
if not values:
return None
return round(sum(values) / len(values), 3)
def turn_eval(turn: Any) -> dict[str, Any] | None:
ev = getattr(turn, "evaluation", None)
return ev if isinstance(ev, dict) else None
def turn_score(ev: dict[str, Any]) -> float | None:
raw = str(ev.get("appropriateness") or "").strip().lower()
return _APPROPRIATENESS_SCORE.get(raw)
def turn_rapport(ev: dict[str, Any]) -> float | None:
value = safe_float(ev.get("rapport_signal"))
if value is None:
return None
return max(-1.0, min(1.0, value))
def turn_techniques(ev: dict[str, Any]) -> list[str]:
raw = ev.get("techniques")
if not isinstance(raw, list):
return []
labels: list[str] = []
for item in raw:
if isinstance(item, dict):
label = item.get("label") or item.get("name") or item.get("id") or item.get("code")
else:
label = item
if label:
labels.append(str(label))
return labels
def turn_feedback_note(ev: dict[str, Any]) -> str | None:
raw = ev.get("appropriateness_note")
if raw is None:
return None
text = str(raw).strip()
return text or None
def session_growth_point(sess: InProcSession) -> SessionGrowthPoint:
scores: list[float] = []
rapports: list[float] = []
technique_count = 0
watch_count = 0
for turn in sess.turns:
if turn.speaker != "counselor":
continue
ev = turn_eval(turn)
if ev is None:
continue
score = turn_score(ev)
if score is not None:
scores.append(score)
if score < 1.0:
watch_count += 1
rapport = turn_rapport(ev)
if rapport is not None:
rapports.append(rapport)
technique_count += len(turn_techniques(ev))
return SessionGrowthPoint(
session_id=sess.session_id,
session_no=sess.session_no,
persona_code=sess.persona_code,
stage=sess.state.stage.value,
started_at=iso_datetime(sess.created_at) or "",
ended_at=iso_datetime(sess.ended_at),
score=avg(scores),
rapport=avg(rapports),
technique_count=technique_count,
watch_count=watch_count,
)
def build_learner_growth(
sessions: list[InProcSession],
*,
learner_label: Callable[[str], str],
limit: int | None = None,
) -> list[LearnerGrowthMetrics]:
grouped: dict[str, list[InProcSession]] = {}
for sess in sessions:
grouped.setdefault(sess.learner_id, []).append(sess)
result: list[LearnerGrowthMetrics] = []
for learner_id, learner_sessions in grouped.items():
ordered = sorted(learner_sessions, key=lambda sess: sess.created_at)
points = [session_growth_point(sess) for sess in ordered]
scored = [point for point in points if point.score is not None]
rapport_values = [point.rapport for point in points if point.rapport is not None]
technique_counts: dict[str, int] = {}
for sess in ordered:
for turn in sess.turns:
if turn.speaker != "counselor":
continue
ev = turn_eval(turn)
if ev is None:
continue
for label in turn_techniques(ev):
technique_counts[label] = technique_counts.get(label, 0) + 1
first_score = scored[0].score if scored else None
latest_score = scored[-1].score if scored else None
score_delta: float | None = None
trend = "insufficient"
if first_score is not None and latest_score is not None:
score_delta = round(latest_score - first_score, 3)
if len(scored) >= 2:
if score_delta >= 0.1:
trend = "up"
elif score_delta <= -0.1:
trend = "down"
else:
trend = "flat"
latest_session = ordered[-1]
top_techniques = [
label
for label, _count in sorted(
technique_counts.items(),
key=lambda item: (-item[1], item[0]),
)[:3]
]
result.append(
LearnerGrowthMetrics(
learner_id=learner_id,
learner_label=learner_label(learner_id),
sessions=len(ordered),
ended_sessions=sum(1 for sess in ordered if sess.ended),
latest_at=iso_datetime(session_activity_time(latest_session)) or "",
first_score=first_score,
latest_score=latest_score,
score_delta=score_delta,
avg_score=avg([point.score for point in scored if point.score is not None]),
avg_rapport=avg([value for value in rapport_values if value is not None]),
trend=trend,
top_techniques=top_techniques,
points=points[-6:],
)
)
sorted_result = sorted(result, key=lambda item: item.latest_at, reverse=True)
return sorted_result if limit is None else sorted_result[:limit]
def recent_feedback_notes(sessions: list[InProcSession], *, limit: int = 5) -> list[dict[str, object]]:
notes: list[dict[str, object]] = []
for sess in sorted(sessions, key=session_activity_time, reverse=True):
for turn in reversed(sess.turns):
if turn.speaker != "counselor":
continue
ev = turn_eval(turn)
if ev is None:
continue
note = turn_feedback_note(ev)
if note is None:
continue
notes.append(
{
"session_id": sess.session_id,
"persona_code": sess.persona_code,
"persona_name": sess.persona.display_name,
"session_no": sess.session_no,
"stage": turn.stage,
"turn_seq": turn.turn_seq,
"created_at": iso_datetime(turn.created_at) or iso_datetime(session_activity_time(sess)) or "",
"score": turn_score(ev),
"rapport": turn_rapport(ev),
"note": note,
"techniques": turn_techniques(ev)[:3],
}
)
if len(notes) >= limit:
return notes
return notes

View file

@ -21,11 +21,12 @@ from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
from typing import AsyncIterator, Optional
from typing import Any, AsyncIterator, Mapping, Optional
import httpx
from ..config import settings
from ..paths import repo_root, repo_path
# ════════════════════════════════════════════════════════════════════════════
# OpenAI 음성 엔드포인트/모델 상수
@ -50,10 +51,9 @@ 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"
repo_path("docs", "voice-art", "p1-seoyeon-higgs-v3-20260627")
)
POC_SAMPLE_TTS_CHUNK_SIZE = 4096
_POC_SAMPLE_TTS_DEFAULT_SAMPLE = "p1_seoyeon_01_depressed_slow"
@ -190,6 +190,51 @@ def resolve_voice(
)
def resolve_voice_from_map(
*,
provider: str,
voice_id: str,
base_params: Mapping[str, Any] | None,
persona_code: Optional[str] = None,
) -> VoicePreset | None:
"""DB persona_voice_map row -> live OpenAI VoicePreset.
provider-agnostic rows are allowed in the catalog, but this service only
knows how to send OpenAI TTS. Unsupported providers return None so callers
can fall back to the existing preset resolver.
"""
if provider.strip().lower() != "openai":
return None
fallback = resolve_voice(persona_code=persona_code)
params = dict(base_params or {})
preset = _clean_optional_text(params.get("preset")) or fallback.preset
mapped_voice = _clean_optional_text(params.get("openai_voice"))
voice_id_value = _clean_optional_text(voice_id)
if not mapped_voice and voice_id_value in _OPENAI_VOICES:
mapped_voice = voice_id_value
if not mapped_voice:
mapped_voice = PRESET_TO_OPENAI_VOICE.get(preset, fallback.openai_voice)
if mapped_voice not in _OPENAI_VOICES:
mapped_voice = fallback.openai_voice
if mapped_voice not in _OPENAI_VOICES:
mapped_voice = DEFAULT_OPENAI_VOICE
rate = PRESET_RATE.get(preset, fallback.rate)
if "rate" in params:
try:
rate = float(params["rate"])
except (TypeError, ValueError):
rate = fallback.rate
return VoicePreset(
preset=preset,
openai_voice=mapped_voice,
rate=rate,
instructions=_clean_optional_text(params.get("instructions")),
)
# 비언어 지문 패턴: (…)·(…)·[…]·【…】. 내담자 발화의 무대지시(고개 끄덕/한숨/침묵 등).
_STAGE_DIRECTION_RE = re.compile(r"[\(\[【][^\)\]】]*[\)\]】]")
@ -291,7 +336,7 @@ class VoiceService:
)
sample_dir = Path(sample_dir_value)
if not sample_dir.is_absolute():
sample_dir = _REPO_ROOT / sample_dir
sample_dir = repo_root() / sample_dir
self._poc_sample_tts_dir = sample_dir
self._client: Optional[httpx.AsyncClient] = None
@ -507,6 +552,13 @@ def _nonnegative_int(value: object) -> int:
return 0
def _clean_optional_text(value: object) -> str | None:
if value is None:
return None
text = str(value).strip()
return text or None
# 앱 전역 싱글톤 (main lifespan 이 startup/shutdown — Foundation 이 관리하거나
# 라우트가 lazy 사용). engine_client 패턴과 동일.
voice_service = VoiceService()
@ -521,6 +573,7 @@ __all__ = [
"VoiceService",
"voice_service",
"resolve_voice",
"resolve_voice_from_map",
"build_tts_payload",
"assess_end_of_turn",
"EOT_SILENCE_THRESHOLD_MS",

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import time
import uuid
import hashlib
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Any, Iterable
@ -13,12 +14,17 @@ from .deps import Principal
from .config import settings
from .persona_repository import SEED_VERSION, card_from_row, seed_fallback_persona, seed_persona_id
from .runtime_policy import require_runtime_fallback_allowed, runtime_fallback_allowed
from .services import memory, state_machine
from .services import guardrail, memory, state_machine
from .services.persona import PersonaCard
from .store import DEFAULT_TURN_VISIBLE_TO, InProcSession, TurnRecord
_EVALUATION_CACHE: dict[str, dict[str, Any]] = {}
_CASE_WORKSHEET_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_REVIEW_STATUS_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_SHARE_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_SHARE_TOKEN_INDEX: dict[str, str] = {}
_LIVE_COACH_EVENT_CACHE: dict[str, list[dict[str, Any]]] = {}
_SESSION_ARCHIVE_CACHE: dict[str, dict[str, Any]] = {}
_SESSION_AUDIT_ROLES = {"teacher", "admin"}
_APPROPRIATENESS_SCORE = {
"warn": 1.0,
@ -50,6 +56,7 @@ _JOINED_CARD_COLUMNS = (
"card_affect_baseline",
"card_ccd",
"card_dsm5_dimensional",
"card_triggers",
"card_source_provenance",
"card_is_synthetic",
)
@ -63,6 +70,84 @@ def _ts(value: datetime | None) -> float | None:
return value.timestamp()
def share_token_hash(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _share_record_from_row(row) -> dict[str, Any]:
return {
"session_id": str(row["session_id"]),
"payload": dict(row["payload"] or {}),
"created_at": _ts(row["created_at"]),
"updated_at": _ts(row["updated_at"]),
"revoked_at": _ts(row["revoked_at"]),
}
def _archive_record_from_row(row) -> dict[str, Any]:
return {
"session_id": str(row["session_id"]),
"learner_id": str(row["learner_id"]),
"archived_at": _ts(row["archived_at"]),
"updated_at": _ts(row["updated_at"]),
}
def _model_payload(value: Any) -> dict[str, Any]:
if hasattr(value, "model_dump"):
return value.model_dump(mode="json")
if isinstance(value, dict):
return dict(value)
return {}
def _masked_excerpt(value: str | None, *, limit: int = 220) -> str | None:
text = (value or "").strip()
if not text:
return None
result = guardrail.mask_pii(text)
masked = str(getattr(result, "text_masked", text)).strip()
compact = " ".join(masked.split())
if len(compact) <= limit:
return compact
return f"{compact[: max(0, limit - 1)].rstrip()}..."
def _live_coach_event_from_row(row) -> dict[str, Any]:
return {
"event_id": str(row["id"]),
"session_id": str(row["session_id"]),
"turn_seq": int(row["turn_seq"] or 1),
"stage": str(row["stage"] or ""),
"created_at": _iso_dt(row["created_at"]),
"learner_text_excerpt": row["learner_text_excerpt"],
"client_reply_excerpt": row["client_reply_excerpt"],
"suggestion": dict(row["payload"] or {}),
}
def _live_coach_cache_record(
*,
session_id: str,
turn_seq: int,
stage: str,
learner_text: str,
client_reply: str | None,
suggestion: Any,
) -> dict[str, Any]:
now = datetime.now(timezone.utc)
return {
"event_id": str(uuid.uuid4()),
"session_id": session_id,
"turn_seq": int(turn_seq),
"stage": stage,
"created_at": now.isoformat().replace("+00:00", "Z"),
"learner_text_excerpt": _masked_excerpt(learner_text),
"client_reply_excerpt": _masked_excerpt(client_reply),
"suggestion": _model_payload(suggestion),
}
def _row_value(row, key: str):
try:
return row[key]
@ -802,6 +887,209 @@ async def ensure_review_tables() -> None:
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.live_coach_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
session_id UUID NOT NULL REFERENCES app.sessions(id) ON DELETE CASCADE,
turn_seq INT NOT NULL CHECK (turn_seq >= 1),
stage TEXT NOT NULL,
learner_text_excerpt TEXT,
client_reply_excerpt TEXT,
payload JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_live_coach_events_session_turn
ON app.live_coach_events(session_id, turn_seq, created_at);
ALTER TABLE app.live_coach_events ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_live_coach_events_select ON app.live_coach_events;
DROP POLICY IF EXISTS p_live_coach_events_insert ON app.live_coach_events;
CREATE POLICY p_live_coach_events_select
ON app.live_coach_events FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.live_coach_events.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_live_coach_events_insert
ON app.live_coach_events FOR INSERT WITH CHECK (
EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.live_coach_events.session_id
AND s.learner_id = app.current_uid()
)
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_review_status (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
reviewer_id UUID REFERENCES app.app_user(user_id),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending','viewed','closed')),
note TEXT NOT NULL DEFAULT '',
reviewed_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)
"""
)
await conn.execute(
"""
ALTER TABLE app.session_review_status ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_review_status_select ON app.session_review_status;
DROP POLICY IF EXISTS p_session_review_status_insert ON app.session_review_status;
DROP POLICY IF EXISTS p_session_review_status_update ON app.session_review_status;
DROP POLICY IF EXISTS p_session_review_status_delete ON app.session_review_status;
CREATE POLICY p_session_review_status_select
ON app.session_review_status FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
);
CREATE POLICY p_session_review_status_insert
ON app.session_review_status FOR INSERT WITH CHECK (
app.current_role_name() IN ('admin','instructor')
);
CREATE POLICY p_session_review_status_update
ON app.session_review_status FOR UPDATE USING (
app.current_role_name() IN ('admin','instructor')
) WITH CHECK (
app.current_role_name() IN ('admin','instructor')
);
CREATE POLICY p_session_review_status_delete
ON app.session_review_status FOR DELETE USING (
app.current_role_name() IN ('admin','instructor')
)
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_share_link (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
created_by UUID NOT NULL REFERENCES app.app_user(user_id),
token_hash TEXT NOT NULL UNIQUE,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
revoked_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_session_share_token_active
ON app.session_share_link(token_hash)
WHERE revoked_at IS NULL;
ALTER TABLE app.session_share_link ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_share_select ON app.session_share_link;
DROP POLICY IF EXISTS p_session_share_insert ON app.session_share_link;
DROP POLICY IF EXISTS p_session_share_update ON app.session_share_link;
DROP POLICY IF EXISTS p_session_share_delete ON app.session_share_link;
CREATE POLICY p_session_share_select
ON app.session_share_link FOR SELECT USING (
app.is_ai_context()
OR app.current_role_name() IN ('admin','instructor')
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_share_insert
ON app.session_share_link FOR INSERT WITH CHECK (
app.is_ai_context()
OR (
created_by = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
)
);
CREATE POLICY p_session_share_update
ON app.session_share_link FOR UPDATE USING (
app.is_ai_context()
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
) WITH CHECK (
app.is_ai_context()
OR (
created_by = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
)
);
CREATE POLICY p_session_share_delete
ON app.session_share_link FOR DELETE USING (
app.is_ai_context()
OR EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_share_link.session_id
AND s.learner_id = app.current_uid()
)
);
"""
)
await conn.execute(
"""
CREATE TABLE IF NOT EXISTS app.session_archive_state (
session_id UUID PRIMARY KEY REFERENCES app.sessions(id) ON DELETE CASCADE,
learner_id UUID NOT NULL REFERENCES app.app_user(user_id),
archived_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_session_archive_learner
ON app.session_archive_state(learner_id, archived_at DESC);
ALTER TABLE app.session_archive_state ENABLE ROW LEVEL SECURITY;
DROP POLICY IF EXISTS p_session_archive_select ON app.session_archive_state;
DROP POLICY IF EXISTS p_session_archive_insert ON app.session_archive_state;
DROP POLICY IF EXISTS p_session_archive_update ON app.session_archive_state;
DROP POLICY IF EXISTS p_session_archive_delete ON app.session_archive_state;
CREATE POLICY p_session_archive_select
ON app.session_archive_state FOR SELECT USING (
app.current_role_name() IN ('admin','instructor')
OR learner_id = app.current_uid()
);
CREATE POLICY p_session_archive_insert
ON app.session_archive_state FOR INSERT WITH CHECK (
learner_id = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_archive_state.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_archive_update
ON app.session_archive_state FOR UPDATE USING (
learner_id = app.current_uid()
) WITH CHECK (
learner_id = app.current_uid()
AND EXISTS (
SELECT 1 FROM app.sessions s
WHERE s.id = app.session_archive_state.session_id
AND s.learner_id = app.current_uid()
)
);
CREATE POLICY p_session_archive_delete
ON app.session_archive_state FOR DELETE USING (
learner_id = app.current_uid()
)
"""
)
except Exception:
return
@ -956,6 +1244,400 @@ async def load_case_worksheet(
return _CASE_WORKSHEET_CACHE.get(session_id), False
async def save_live_coach_event(
*,
session_id: str,
learner_id: str,
turn_seq: int,
stage: str,
learner_text: str,
client_reply: str | None,
suggestion: Any,
) -> tuple[dict[str, Any] | None, bool]:
record = _live_coach_cache_record(
session_id=session_id,
turn_seq=turn_seq,
stage=stage,
learner_text=learner_text,
client_reply=client_reply,
suggestion=suggestion,
)
if runtime_fallback_allowed():
_LIVE_COACH_EVENT_CACHE.setdefault(session_id, []).append(record)
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.live_coach_events (
session_id, turn_seq, stage, learner_text_excerpt,
client_reply_excerpt, payload, created_at
)
VALUES ($1::uuid, $2, $3, $4, $5, $6::jsonb, now())
RETURNING id, session_id, turn_seq, stage, learner_text_excerpt,
client_reply_excerpt, payload, created_at
""",
session_id,
int(turn_seq),
stage,
record.get("learner_text_excerpt"),
record.get("client_reply_excerpt"),
_model_payload(suggestion),
)
return (_live_coach_event_from_row(row) if row else None), True
except Exception:
require_runtime_fallback_allowed("live coach event save")
return record, False
async def list_live_coach_events(
session_id: str,
principal: Principal,
) -> tuple[list[dict[str, Any]], bool]:
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT id, session_id, turn_seq, stage, learner_text_excerpt,
client_reply_excerpt, payload, created_at
FROM app.live_coach_events
WHERE session_id = $1::uuid
ORDER BY created_at ASC, turn_seq ASC
""",
session_id,
)
return [_live_coach_event_from_row(row) for row in rows], True
except Exception:
require_runtime_fallback_allowed("live coach event list")
return [dict(item) for item in _LIVE_COACH_EVENT_CACHE.get(session_id, [])], False
def _review_status_from_row(row: Any) -> dict[str, Any]:
return {
"session_id": str(row["session_id"]),
"reviewer_id": str(row["reviewer_id"] or ""),
"status": str(row["status"] or "pending"),
"note": str(row["note"] or ""),
"reviewed_at": _iso_dt(row["reviewed_at"]),
"updated_at": _iso_dt(row["updated_at"]),
}
def _review_status_cache_record(
*,
session_id: str,
reviewer_id: str,
status: str,
note: str,
) -> dict[str, Any]:
now = datetime.now(timezone.utc)
previous = _SESSION_REVIEW_STATUS_CACHE.get(session_id) or {}
reviewed_at = previous.get("reviewed_at")
if status == "closed" and not reviewed_at:
reviewed_at = now.isoformat().replace("+00:00", "Z")
if status != "closed":
reviewed_at = None
return {
"session_id": session_id,
"reviewer_id": reviewer_id,
"status": status,
"note": note,
"reviewed_at": reviewed_at,
"updated_at": now.isoformat().replace("+00:00", "Z"),
}
async def list_session_review_statuses(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
if not session_ids:
return {}, True
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT session_id, reviewer_id, status, note, reviewed_at, updated_at
FROM app.session_review_status
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
return {
str(row["session_id"]): _review_status_from_row(row)
for row in rows
}, True
except Exception:
require_runtime_fallback_allowed("session review status list")
return {
session_id: dict(record)
for session_id in session_ids
if (record := _SESSION_REVIEW_STATUS_CACHE.get(session_id)) is not None
}, False
async def load_session_review_status(
session_id: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
statuses, durable = await list_session_review_statuses([session_id], principal)
return statuses.get(session_id), durable
async def save_session_review_status(
*,
session_id: str,
reviewer_id: str,
status: str,
note: str,
principal: Principal,
) -> tuple[dict[str, Any] | None, bool]:
note = note.strip()
if runtime_fallback_allowed():
_SESSION_REVIEW_STATUS_CACHE[session_id] = _review_status_cache_record(
session_id=session_id,
reviewer_id=reviewer_id,
status=status,
note=note,
)
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.session_review_status (
session_id, reviewer_id, status, note, reviewed_at, updated_at
)
VALUES (
$1::uuid, $2::uuid, $3, $4,
CASE WHEN $3 = 'closed' THEN now() ELSE NULL END,
now()
)
ON CONFLICT (session_id) DO UPDATE SET
reviewer_id = EXCLUDED.reviewer_id,
status = EXCLUDED.status,
note = EXCLUDED.note,
reviewed_at = CASE
WHEN EXCLUDED.status = 'closed'
THEN COALESCE(app.session_review_status.reviewed_at, now())
ELSE NULL
END,
updated_at = now()
RETURNING session_id, reviewer_id, status, note, reviewed_at, updated_at
""",
session_id,
reviewer_id,
status,
note,
)
return (_review_status_from_row(row) if row else None), True
except Exception:
require_runtime_fallback_allowed("session review status save")
return _SESSION_REVIEW_STATUS_CACHE.get(session_id), False
async def save_session_share(
*,
session_id: str,
learner_id: str,
token_hash: str,
payload: dict[str, Any],
) -> dict[str, Any] | None:
record = {
"session_id": session_id,
"payload": payload,
"created_at": time.time(),
"updated_at": time.time(),
"revoked_at": None,
}
if runtime_fallback_allowed():
old = _SESSION_SHARE_CACHE.get(session_id)
if old:
_SESSION_SHARE_TOKEN_INDEX.pop(str(old.get("token_hash") or ""), None)
_SESSION_SHARE_CACHE[session_id] = {**record, "token_hash": token_hash}
_SESSION_SHARE_TOKEN_INDEX[token_hash] = session_id
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
row = await conn.fetchrow(
"""
INSERT INTO app.session_share_link (
session_id, created_by, token_hash, payload, created_at, updated_at, revoked_at
)
VALUES ($1::uuid, $2::uuid, $3, $4::jsonb, now(), now(), NULL)
ON CONFLICT (session_id) DO UPDATE SET
created_by = EXCLUDED.created_by,
token_hash = EXCLUDED.token_hash,
payload = EXCLUDED.payload,
updated_at = now(),
revoked_at = NULL
RETURNING session_id, payload, created_at, updated_at, revoked_at
""",
session_id,
learner_id,
token_hash,
payload,
)
return _share_record_from_row(row) if row is not None else None
except Exception:
require_runtime_fallback_allowed("session share save")
return record
async def revoke_session_share(*, session_id: str, learner_id: str) -> bool:
if runtime_fallback_allowed():
old = _SESSION_SHARE_CACHE.pop(session_id, None)
if old:
_SESSION_SHARE_TOKEN_INDEX.pop(str(old.get("token_hash") or ""), None)
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
status = await conn.execute(
"""
UPDATE app.session_share_link
SET revoked_at = now(), updated_at = now()
WHERE session_id = $1::uuid
""",
session_id,
)
return status != "UPDATE 0"
except Exception:
require_runtime_fallback_allowed("session share revoke")
return True
async def load_public_session_share(token_hash: str) -> dict[str, Any] | None:
try:
get_pool()
async with acquire(ai_context=True) as conn:
row = await conn.fetchrow(
"""
SELECT session_id, payload, created_at, updated_at, revoked_at
FROM app.session_share_link
WHERE token_hash = $1
AND revoked_at IS NULL
""",
token_hash,
)
return _share_record_from_row(row) if row is not None else None
except Exception:
require_runtime_fallback_allowed("session share public load")
session_id = _SESSION_SHARE_TOKEN_INDEX.get(token_hash)
if not session_id:
return None
cached = _SESSION_SHARE_CACHE.get(session_id)
if not cached or cached.get("revoked_at") is not None:
return None
return {
"session_id": str(cached["session_id"]),
"payload": dict(cached.get("payload") or {}),
"created_at": cached.get("created_at"),
"updated_at": cached.get("updated_at"),
"revoked_at": cached.get("revoked_at"),
}
async def list_session_archives(
session_ids: list[str],
principal: Principal,
) -> tuple[dict[str, dict[str, Any]], bool]:
"""Return learner archive state keyed by session id."""
if not session_ids:
return {}, True
try:
get_pool()
async with acquire(
role=principal.role.value,
user_id=principal.user_id,
cohort_ids=principal.cohort_ids,
) as conn:
rows = await conn.fetch(
"""
SELECT session_id, learner_id, archived_at, updated_at
FROM app.session_archive_state
WHERE session_id = ANY($1::uuid[])
""",
session_ids,
)
return {str(row["session_id"]): _archive_record_from_row(row) for row in rows}, True
except Exception:
require_runtime_fallback_allowed("session archive list")
return {
session_id: dict(record)
for session_id in session_ids
if (record := _SESSION_ARCHIVE_CACHE.get(session_id)) is not None
}, False
async def set_session_archived(
*,
session_id: str,
learner_id: str,
archived: bool,
) -> tuple[dict[str, Any] | None, bool]:
"""Archive or restore a learner-owned session without deleting evidence."""
now = datetime.now(timezone.utc)
cache_record = {
"session_id": session_id,
"learner_id": learner_id,
"archived_at": now.timestamp(),
"updated_at": now.timestamp(),
}
if runtime_fallback_allowed():
if archived:
_SESSION_ARCHIVE_CACHE[session_id] = cache_record
else:
_SESSION_ARCHIVE_CACHE.pop(session_id, None)
try:
get_pool()
async with acquire(role="learner", user_id=learner_id) as conn:
if archived:
row = await conn.fetchrow(
"""
INSERT INTO app.session_archive_state (
session_id, learner_id, archived_at, updated_at
)
VALUES ($1::uuid, $2::uuid, now(), now())
ON CONFLICT (session_id) DO UPDATE SET
learner_id = EXCLUDED.learner_id,
archived_at = COALESCE(app.session_archive_state.archived_at, EXCLUDED.archived_at),
updated_at = now()
RETURNING session_id, learner_id, archived_at, updated_at
""",
session_id,
learner_id,
)
return _archive_record_from_row(row), True
deleted = await conn.fetchrow(
"""
DELETE FROM app.session_archive_state
WHERE session_id = $1::uuid
AND learner_id = $2::uuid
RETURNING session_id, learner_id, archived_at, updated_at
""",
session_id,
learner_id,
)
return (_archive_record_from_row(deleted) if deleted is not None else None), True
except Exception:
require_runtime_fallback_allowed("session archive update")
return (_SESSION_ARCHIVE_CACHE.get(session_id) if archived else None), False
def _session_from_rows(row, state_row, turn_rows: Iterable) -> InProcSession | None:
card = _card_from_joined_session_row(row)
if card is None:
@ -1180,6 +1862,7 @@ async def load_session(
pc.affect_baseline AS card_affect_baseline,
pc.ccd AS card_ccd,
pc.dsm5_dimensional AS card_dsm5_dimensional,
pc.triggers AS card_triggers,
pc.source_provenance AS card_source_provenance,
pc.is_synthetic AS card_is_synthetic
FROM app.sessions s
@ -1398,6 +2081,7 @@ async def list_sessions(
pc.affect_baseline AS card_affect_baseline,
pc.ccd AS card_ccd,
pc.dsm5_dimensional AS card_dsm5_dimensional,
pc.triggers AS card_triggers,
pc.source_provenance AS card_source_provenance,
pc.is_synthetic AS card_is_synthetic
FROM app.sessions s

View file

@ -0,0 +1,301 @@
"""Admin operations persistence model tests."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from .deps import Principal, Role
from .routes import admin as admin_routes
class _Acquire:
def __init__(self, conn):
self.conn = conn
async def __aenter__(self):
return self.conn
async def __aexit__(self, exc_type, exc, tb):
return None
class AdminOpsTest(unittest.IsolatedAsyncioTestCase):
async def test_usage_from_database_orders_by_aggregated_token_sum(self) -> None:
case = self
class Conn:
async def fetchrow(self, query, *args, **kwargs):
return {
"total_turns": 2,
"metered_turns": 1,
"tokens_in": 11,
"tokens_out": 13,
"cost_usd": 0.0042,
}
async def fetch(self, query, *args, **kwargs):
case.assertIn(
"COALESCE(SUM(tokens_in), 0) + COALESCE(SUM(tokens_out), 0) DESC",
query,
)
case.assertNotIn("tokens_in + tokens_out DESC", query)
return [
{
"provider": "claude_cli",
"model": "gateway-default",
"turns": 1,
"tokens_in": 11,
"tokens_out": 13,
"cost_usd": 0.0042,
}
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
usage = await admin_routes._usage_from_database(window_days=7)
self.assertTrue(usage.durable)
self.assertEqual(usage.source, "database")
self.assertEqual(usage.total_turns, 2)
self.assertEqual(usage.metered_turns, 1)
self.assertEqual(usage.by_provider[0].provider, "claude_cli")
async def test_uptime_from_database_aggregates_health_samples(self) -> None:
now = datetime.now(timezone.utc)
class Conn:
async def fetch(self, *args, **kwargs):
return [
{
"id": 3,
"observed_at": now,
"overall_status": "ok",
"service_key": "db",
"service_name": "영구 저장소",
"service_status": "ok",
"detail": "사용자, 세션, 리뷰 저장",
"metric": "풀 1/10",
"load": 0.1,
},
{
"id": 2,
"observed_at": now - timedelta(minutes=5),
"overall_status": "down",
"service_key": "engine",
"service_name": "응답 생성",
"service_status": "down",
"detail": "Engine readiness failed",
"metric": "로그인/설정 필요",
"load": 0.0,
},
{
"id": 1,
"observed_at": now - timedelta(minutes=10),
"overall_status": "degraded",
"service_key": "voice",
"service_name": "음성 입력",
"service_status": "degraded",
"detail": "음성 입력과 재생",
"metric": "설정 필요",
"load": 0.0,
},
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
uptime = await admin_routes._uptime_from_database(window_hours=24)
self.assertTrue(uptime.durable)
self.assertEqual(uptime.sample_count, 3)
self.assertEqual(uptime.down_events, 1)
self.assertEqual(uptime.degraded_events, 1)
self.assertAlmostEqual(uptime.ok_ratio, 1 / 3, places=4)
self.assertIsNotNone(uptime.last_down_at)
self.assertEqual({item.service_key for item in uptime.services}, {"db", "engine", "voice"})
async def test_tickets_from_database_returns_summary_without_synthetic_rows(self) -> None:
now = datetime.now(timezone.utc)
class Conn:
async def fetch(self, *args, **kwargs):
return [
{
"id": "00000000-0000-0000-0000-000000000101",
"reporter_id": "00000000-0000-0000-0000-000000000201",
"reporter_email": "learner@hs.ac.kr",
"reporter_name": "Learner",
"reporter_role": "learner",
"category": "session_review",
"priority": "high",
"status": "open",
"subject": "리뷰 지연",
"body": "회기 리뷰가 생성되지 않습니다.",
"source_path": "/learn/session/1/review",
"assigned_group": "",
"resolution_note": "",
"created_at": now - timedelta(days=2),
"updated_at": now - timedelta(days=2),
"resolved_at": None,
},
{
"id": "00000000-0000-0000-0000-000000000102",
"reporter_id": None,
"reporter_email": "teacher@hs.ac.kr",
"reporter_name": "Teacher",
"reporter_role": "teacher",
"category": "voice_browser",
"priority": "high",
"status": "resolved",
"subject": "마이크 권한",
"body": "브라우저 권한 안내가 필요합니다.",
"source_path": "/learn/session/2",
"assigned_group": "서비스 운영자",
"resolution_note": "안내 완료",
"created_at": now,
"updated_at": now,
"resolved_at": now,
},
]
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
tickets = await admin_routes._tickets_from_database(
ticket_status=None,
category=None,
priority=None,
assigned_group=None,
source_path=None,
stale_only=False,
search="",
window_days=30,
)
self.assertTrue(tickets.durable)
self.assertEqual(tickets.source, "database")
self.assertEqual(len(tickets.tickets), 2)
self.assertEqual(tickets.summary.total, 2)
self.assertEqual(tickets.summary.open_count, 1)
self.assertEqual(tickets.summary.high_priority_count, 1)
self.assertEqual(tickets.summary.stale_count, 1)
self.assertEqual(tickets.summary.by_category["session_review"], 1)
async def test_tickets_from_database_applies_queue_filters(self) -> None:
class Conn:
async def fetch(self, query, *args, **kwargs):
self.query = query
self.args = args
return []
conn = Conn()
with patch.object(admin_routes, "acquire", return_value=_Acquire(conn)):
tickets = await admin_routes._tickets_from_database(
ticket_status="open",
category="voice_browser",
priority="urgent",
assigned_group="서비스 운영자",
source_path="/learn/session/1",
stale_only=True,
search="마이크",
window_days=7,
)
self.assertTrue(tickets.durable)
self.assertIn("assigned_group = $4", conn.query)
self.assertIn("lower(subject)", conn.query)
self.assertEqual(
conn.args,
(
"open",
"voice_browser",
"urgent",
"서비스 운영자",
"/learn/session/1",
True,
"마이크",
7,
),
)
async def test_patch_ticket_records_metadata_only_audit(self) -> None:
now = datetime.now(timezone.utc)
ticket_id = "00000000-0000-0000-0000-000000000101"
audit_calls: list[tuple[str, tuple[object, ...]]] = []
class Conn:
def __init__(self):
self.fetchrow_calls = 0
async def fetchrow(self, query, *args, **kwargs):
self.fetchrow_calls += 1
if self.fetchrow_calls == 1:
return {
"id": ticket_id,
"category": "session_review",
"priority": "high",
"status": "open",
"source_path": "/learn/session/1/review",
"assigned_group": "",
"resolution_note": "",
}
return {
"id": ticket_id,
"reporter_id": "00000000-0000-0000-0000-000000000201",
"reporter_email": "learner@hs.ac.kr",
"reporter_name": "Learner",
"reporter_role": "learner",
"category": "session_review",
"priority": "urgent",
"status": "in_progress",
"subject": "리뷰 지연",
"body": "회기 리뷰가 생성되지 않습니다.",
"source_path": "/learn/session/1/review",
"assigned_group": "서비스 운영자",
"resolution_note": "",
"created_at": now,
"updated_at": now,
"resolved_at": None,
"event_count": 1,
"last_event_at": now,
}
async def execute(self, query, *args, **kwargs):
audit_calls.append((query, args))
principal = Principal(
user_id="00000000-0000-0000-0000-000000000301",
role=Role.ADMIN,
admin_access=True,
email="admin@hs.ac.kr",
display_name="Admin",
)
with patch.object(admin_routes, "acquire", return_value=_Acquire(Conn())):
updated = await admin_routes.patch_ticket(
ticket_id,
admin_routes.AdminTicketPatch(
status="in_progress",
priority="urgent",
assigned_group="서비스 운영자",
),
principal,
)
self.assertEqual(updated.status, "in_progress")
self.assertEqual(updated.event_count, 1)
self.assertEqual(len(audit_calls), 1)
query, args = audit_calls[0]
self.assertIn("INSERT INTO audit.audit_log", query)
self.assertEqual(args[1], "support_ticket_update")
detail = args[4]
self.assertEqual(set(detail["changed_fields"]), {"status", "priority", "assigned_group"})
self.assertNotIn("body", detail)
self.assertNotIn("subject", detail)
def test_admin_ops_schema_enables_rls(self) -> None:
from pathlib import Path
root = Path(__file__).resolve().parents[3]
schema = (root / "infra" / "db" / "init" / "05_runtime_auth.sql").read_text(
encoding="utf-8"
)
self.assertIn("ALTER TABLE app.admin_health_event ENABLE ROW LEVEL SECURITY", schema)
self.assertIn("ALTER TABLE app.support_ticket ENABLE ROW LEVEL SECURITY", schema)
self.assertIn("CREATE POLICY p_support_ticket_insert", schema)

View file

@ -0,0 +1,401 @@
"""학습자 개인 대시보드 집계 테스트."""
from __future__ import annotations
import unittest
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from .routes import sessions
from .services import state_machine
from .services.persona import P1, P2
from .store import InProcSession, TurnRecord
def _principal() -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000501",
role=Role.LEARNER,
)
def _session(
*,
session_id: str,
session_no: int,
persona_code: str,
score: str,
rapport: float,
technique: str,
created_at: float,
ended: bool = True,
) -> InProcSession:
persona = P2 if persona_code == P2.code else P1
state = state_machine.init_state(params=persona.openness_params())
state.stage = state_machine.Stage.EXPLORE
return InProcSession(
session_id=session_id,
case_id=session_id,
learner_id=_principal().user_id,
persona_code=persona.code,
theory_mode="humanistic",
persona=persona,
state=state,
session_no=session_no,
created_at=created_at,
ended_at=created_at + 600 if ended else None,
ended=ended,
turns=[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=state.stage.value,
text="상담자 발화",
text_masked="상담자 발화",
evaluation={
"appropriateness": score,
"appropriateness_note": f"{technique} 근거 기반 피드백",
"rapport_signal": rapport,
"techniques": [{"label": technique}],
},
),
TurnRecord(
turn_seq=2,
speaker="client",
stage=state.stage.value,
text="내담자 응답",
text_masked="내담자 응답",
),
],
)
class LearnerDashboardTest(unittest.IsolatedAsyncioTestCase):
async def test_dashboard_returns_personalized_growth_from_owned_sessions(self) -> None:
principal = _principal()
owned_sessions = [
_session(
session_id="00000000-0000-0000-0000-00000000b111",
session_no=1,
persona_code=P1.code,
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
),
_session(
session_id="00000000-0000-0000-0000-00000000b112",
session_no=2,
persona_code=P1.code,
score="pos",
rapport=0.5,
technique="reflection",
created_at=2_000.0,
ended=False,
),
_session(
session_id="00000000-0000-0000-0000-00000000b113",
session_no=3,
persona_code=P2.code,
score="warn",
rapport=-0.2,
technique="open question",
created_at=3_000.0,
),
]
async def load_review(session_id: str, _principal_arg: Principal):
status = "ready" if session_id.endswith("b111") or session_id.endswith("b113") else None
if status is None:
return None, True
return {"status": status}, True
with (
patch.object(
sessions.session_persistence,
"list_sessions",
AsyncMock(return_value=(owned_sessions, True)),
) as list_sessions,
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(side_effect=load_review),
),
patch.object(
sessions.session_persistence,
"list_session_archives",
AsyncMock(return_value=({}, True)),
) as list_session_archives,
):
response = await sessions.learner_dashboard(principal)
list_sessions.assert_awaited_once_with(principal, include_turn_evaluation=True)
list_session_archives.assert_awaited_once_with(
[sess.session_id for sess in owned_sessions],
principal,
)
self.assertEqual(response.source, "database")
self.assertEqual(response.overview.total_sessions, 3)
self.assertEqual(response.overview.completed_sessions, 2)
self.assertEqual(response.overview.active_sessions, 1)
self.assertEqual(response.overview.review_ready_sessions, 2)
self.assertEqual(response.overview.archived_sessions, 0)
self.assertEqual(response.overview.learner_turns, 3)
self.assertEqual(response.overview.client_turns, 3)
self.assertEqual(response.growth.first_score, 0.5)
self.assertEqual(response.growth.latest_score, 0.25)
self.assertEqual(response.growth.trend, "down")
self.assertEqual(response.growth.evaluated_sessions, 3)
self.assertEqual(response.growth.top_techniques, ["reflection", "open question"])
self.assertEqual([row.persona_code for row in response.persona_progress], [P2.code, P1.code])
self.assertEqual(response.persona_progress[0].review_ready_sessions, 1)
self.assertEqual(response.persona_progress[1].sessions, 2)
self.assertEqual(response.achievements[0].state, "done")
self.assertEqual(response.achievements[1].state, "done")
self.assertEqual(len(response.recent_feedback), 3)
self.assertIn("근거 기반 피드백", response.recent_feedback[0].note)
async def test_session_list_marks_archived_rows_from_learner_archive_state(self) -> None:
principal = _principal()
owned_sessions = [
_session(
session_id="00000000-0000-0000-0000-00000000c111",
session_no=1,
persona_code=P1.code,
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
),
_session(
session_id="00000000-0000-0000-0000-00000000c112",
session_no=2,
persona_code=P2.code,
score="pos",
rapport=0.4,
technique="summarizing",
created_at=2_000.0,
),
]
archived_at = 4_000.0
archive_records = {
owned_sessions[1].session_id: {
"session_id": owned_sessions[1].session_id,
"learner_id": principal.user_id,
"archived_at": archived_at,
"updated_at": archived_at,
}
}
with (
patch.object(
sessions.session_persistence,
"list_sessions",
AsyncMock(return_value=(owned_sessions, True)),
) as list_sessions,
patch.object(
sessions.session_persistence,
"list_session_archives",
AsyncMock(return_value=(archive_records, True)),
) as list_session_archives,
patch.object(sessions, "_review_ready", AsyncMock(return_value=False)),
):
response = await sessions.list_learner_sessions(principal)
list_sessions.assert_awaited_once_with(principal)
list_session_archives.assert_awaited_once_with(
[sess.session_id for sess in owned_sessions],
principal,
)
self.assertEqual(response.source, "database")
self.assertEqual([item.session_id for item in response.sessions], [sess.session_id for sess in owned_sessions])
self.assertEqual([item.archived for item in response.sessions], [False, True])
self.assertIsNone(response.sessions[0].archived_at)
self.assertEqual(response.sessions[1].archived_at, sessions._iso(archived_at))
async def test_dashboard_counts_archived_sessions_outside_review_ready_queue(self) -> None:
principal = _principal()
owned_sessions = [
_session(
session_id="00000000-0000-0000-0000-00000000d111",
session_no=1,
persona_code=P1.code,
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
),
_session(
session_id="00000000-0000-0000-0000-00000000d112",
session_no=2,
persona_code=P1.code,
score="pos",
rapport=0.5,
technique="reflection",
created_at=2_000.0,
ended=False,
),
_session(
session_id="00000000-0000-0000-0000-00000000d113",
session_no=3,
persona_code=P2.code,
score="warn",
rapport=-0.2,
technique="open question",
created_at=3_000.0,
),
]
archive_records = {
owned_sessions[2].session_id: {
"session_id": owned_sessions[2].session_id,
"learner_id": principal.user_id,
"archived_at": 4_000.0,
"updated_at": 4_000.0,
}
}
async def load_review(session_id: str, _principal_arg: Principal):
status = "ready" if session_id.endswith("d111") or session_id.endswith("d113") else None
if status is None:
return None, True
return {"status": status}, True
with (
patch.object(
sessions.session_persistence,
"list_sessions",
AsyncMock(return_value=(owned_sessions, True)),
),
patch.object(
sessions.session_persistence,
"list_session_archives",
AsyncMock(return_value=(archive_records, True)),
) as list_session_archives,
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(side_effect=load_review),
),
):
response = await sessions.learner_dashboard(principal)
list_session_archives.assert_awaited_once_with(
[sess.session_id for sess in owned_sessions],
principal,
)
self.assertEqual(response.overview.total_sessions, 3)
self.assertEqual(response.overview.archived_sessions, 1)
self.assertEqual(response.overview.review_ready_sessions, 1)
self.assertEqual(response.persona_progress[0].persona_code, P2.code)
self.assertEqual(response.persona_progress[0].review_ready_sessions, 0)
async def test_archive_session_uses_owned_load_and_sets_learner_archive_state(self) -> None:
principal = _principal()
sess = _session(
session_id="00000000-0000-0000-0000-00000000e111",
session_no=1,
persona_code=P1.code,
score="pos",
rapport=0.3,
technique="reflection",
created_at=1_000.0,
)
archived_at = 4_000.0
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=sess),
) as load_session,
patch.object(
sessions.session_persistence,
"set_session_archived",
AsyncMock(
return_value=(
{
"session_id": sess.session_id,
"learner_id": principal.user_id,
"archived_at": archived_at,
"updated_at": archived_at,
},
True,
)
),
) as set_session_archived,
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=({"status": "ready"}, True)),
),
):
response = await sessions.archive_session(sess.session_id, principal)
load_session.assert_awaited_once_with(
sess.session_id,
principal,
allow_ended=True,
include_turn_evaluation=False,
)
set_session_archived.assert_awaited_once_with(
session_id=sess.session_id,
learner_id=principal.user_id,
archived=True,
)
self.assertEqual(response.source, "database")
self.assertTrue(response.archived)
self.assertEqual(response.archived_at, sessions._iso(archived_at))
self.assertTrue(response.session.archived)
self.assertTrue(response.session.review_ready)
async def test_restore_session_uses_owned_load_and_clears_learner_archive_state(self) -> None:
principal = _principal()
sess = _session(
session_id="00000000-0000-0000-0000-00000000e112",
session_no=1,
persona_code=P2.code,
score="pos",
rapport=0.3,
technique="reflection",
created_at=1_000.0,
)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=sess),
) as load_session,
patch.object(
sessions.session_persistence,
"set_session_archived",
AsyncMock(return_value=(None, True)),
) as set_session_archived,
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(None, True)),
),
):
response = await sessions.restore_archived_session(sess.session_id, principal)
load_session.assert_awaited_once_with(
sess.session_id,
principal,
allow_ended=True,
include_turn_evaluation=False,
)
set_session_archived.assert_awaited_once_with(
session_id=sess.session_id,
learner_id=principal.user_id,
archived=False,
)
self.assertEqual(response.source, "database")
self.assertFalse(response.archived)
self.assertIsNone(response.archived_at)
self.assertFalse(response.session.archived)
self.assertIsNone(response.session.archived_at)
if __name__ == "__main__":
unittest.main()

View file

@ -0,0 +1,43 @@
"""라이브 코칭 source pack → RAG 색인 payload 변환 테스트."""
from __future__ import annotations
import unittest
from .services import live_coach
class LiveCoachSourcePackTest(unittest.TestCase):
def test_source_packs_build_rag_rows_and_index_payloads(self) -> None:
rows = live_coach.build_rag_source_rows()
payloads = live_coach.build_rag_index_payloads()
row_ids = {row["source_id"] for row in rows}
payload_ids = {payload["source_id"] for payload in payloads}
self.assertIn("workbook_0615_case_conceptualization", row_ids)
self.assertIn("dsm5tr_case_formulation", row_ids)
self.assertIn("official_suicide_risk_guidelines", row_ids)
self.assertEqual(row_ids, payload_ids)
for row in rows:
self.assertTrue(row["title"])
self.assertIn(row["license_class"], {"A", "B", "C", "D"})
self.assertTrue(row["external_llm_ok"])
for payload in payloads:
self.assertRegex(payload["content_hash"], r"^[0-9a-f]{64}$")
self.assertGreater(payload["version"], 0)
self.assertTrue(payload["chunks"])
for chunk in payload["chunks"]:
self.assertTrue(chunk["chunk_text"])
self.assertEqual(chunk["visible_to"], ["evaluator"])
self.assertNotIn("client", chunk["visible_to"])
self.assertTrue(chunk["meta"]["live_coaching_source"])
self.assertTrue(chunk["meta"]["citation"])
dsm = next(payload for payload in payloads if payload["source_id"] == "dsm5tr_case_formulation")
self.assertTrue(all(chunk["sensitivity"] == 2 for chunk in dsm["chunks"]))
if __name__ == "__main__":
unittest.main()

View file

@ -8,8 +8,9 @@ from unittest.mock import AsyncMock, patch
from fastapi import HTTPException
from . import persona_repository
from . import persona_repository, session_persistence
from .deps import Principal, Role
from .engine_client import GenerateResponse
from .persona_repository import PersonaDraftRecord, PersonaReviewItem
from .routes import personas, sessions
from .services import persona as persona_service
@ -23,6 +24,7 @@ def _principal(role: Role = Role.LEARNER) -> Principal:
email=f"{role.value}@example.test",
display_name=role.value.title(),
consent_at=1.0 if role == Role.LEARNER else None,
profile_completed_at=1.0 if role == Role.LEARNER else None,
)
@ -50,6 +52,7 @@ def _card_row(
"affect_baseline": dict(card.affect_baseline),
"ccd": dict(card.ccd),
"dsm5_dimensional": dict(card.dsm5_dimensional),
"triggers": dict(card.triggers),
"source_provenance": card.source_provenance,
"is_synthetic": card.is_synthetic,
"created_at": "2026-01-01T00:00:00",
@ -83,6 +86,24 @@ class _PersonaCardConn:
async def fetchrow(self, query: str, *args: Any) -> dict[str, Any] | None:
self.fetchrow_calls.append((query, args))
if "UPDATE app.persona_card" in query:
if "archived AS" in query:
persona_id = str(args[0])
target = next(
(row for row in self.rows if row["persona_id"] == persona_id and row["status"] != "archived"),
None,
)
if target is None:
return None
code = str(target["code"]).upper()
returned: dict[str, Any] | None = None
for row in self.rows:
if str(row["code"]).upper() == code and row["status"] != "archived":
row["status"] = "archived"
row["approved_by"] = None
row["approved_at"] = None
if row["persona_id"] == persona_id:
returned = row
return returned
if "display_name = $4" in query:
persona_id = str(args[0])
next_status = str(args[2])
@ -107,6 +128,7 @@ class _PersonaCardConn:
"dsm5_dimensional": dict(args[14]),
"source_provenance": args[15],
"is_synthetic": bool(args[16]),
"triggers": dict(args[17]),
"approved_by": None,
"approved_at": None,
}
@ -144,16 +166,30 @@ class _PersonaCardConn:
"dsm5_dimensional": dict(args[15]),
"source_provenance": args[16],
"is_synthetic": bool(args[17]),
"created_by": args[18],
"triggers": dict(args[18]),
"created_by": args[19],
"approved_by": None,
"created_at": "2026-01-04T00:00:00",
"approved_at": None,
}
self.rows.append(row)
return row
if "upper(code) = upper($1)" in query and "status IN ('draft', 'review')" in query:
code = str(args[0]).upper()
matches = [
row
for row in self.rows
if str(row["code"]).upper() == code and row["status"] in {"draft", "review"}
]
matches.sort(key=lambda row: int(row["version"]), reverse=True)
return matches[0] if matches else None
if "WHERE persona_id = $1::uuid" in query:
persona_id = str(args[0])
for row in self.rows:
if row["persona_id"] == persona_id and "status = 'approved'" in query:
if row["status"] == "approved":
return row
return None
if row["persona_id"] == persona_id and row["status"] in {"draft", "review"}:
return row
return None
@ -201,6 +237,7 @@ def _draft_payload(
affect_baseline=dict(card.affect_baseline),
ccd=dict(card.ccd),
dsm5_dimensional=dict(card.dsm5_dimensional),
triggers=dict(card.triggers),
source_provenance=card.source_provenance,
is_synthetic=card.is_synthetic,
submit_for_review=submit_for_review,
@ -235,8 +272,34 @@ class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
count = await persona_repository.materialize_seed_personas()
self.assertEqual(count, 7)
inserted_codes = [args[1] for _, args in conn.execute_calls]
persona_card_calls = [
(query, args)
for query, args in conn.execute_calls
if "INSERT INTO app.persona_card" in query
]
voice_map_calls = [
(query, args)
for query, args in conn.execute_calls
if "INSERT INTO app.persona_voice_map" in query
]
inserted_codes = [args[1] for _, args in persona_card_calls]
self.assertEqual(inserted_codes, ["P1", "P2", "P3", "P4", "P5", "P6", "P7"])
self.assertEqual(len(voice_map_calls), 7)
self.assertEqual(voice_map_calls[0][1][2], "coral")
self.assertEqual(voice_map_calls[0][1][3]["preset"], "soft-young-fem")
async def test_materialize_seed_personas_does_not_overwrite_existing_rows(self) -> None:
conn = _PersonaCardConn([])
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
await persona_repository.materialize_seed_personas()
query, _ = conn.execute_calls[0]
self.assertIn("ON CONFLICT (code, version) DO NOTHING", query)
self.assertNotIn("DO UPDATE SET", query)
async def test_catalog_repository_lists_only_approved_personas(self) -> None:
conn = _PersonaCardConn(
@ -325,6 +388,26 @@ class PersonaApprovalBoundaryTest(unittest.IsolatedAsyncioTestCase):
get_persona.assert_awaited_once_with("P2")
create_session.assert_not_awaited()
async def test_session_joined_card_hydrates_triggers_for_runtime_prompt(self) -> None:
row = _card_row(
persona_service.P2,
persona_id="00000000-0000-0000-0000-000000000107",
status="approved",
)
row["triggers"] = {
"sore_spots": ["무능하다는 낙인"],
"forbidden": ["비웃음"],
"reaction": "말수가 급격히 줄고 방어한다.",
}
joined_row = {f"card_{key}": value for key, value in row.items()}
card = session_persistence._card_from_joined_session_row(joined_row)
self.assertIsNotNone(card)
assert card is not None
self.assertEqual(card.triggers["sore_spots"], ["무능하다는 낙인"])
self.assertEqual(card.triggers["forbidden"], ["비웃음"])
class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
async def test_review_queue_repository_fetches_draft_and_review_for_teacher(self) -> None:
@ -414,6 +497,69 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(audit_args[1], "persona_draft_create")
self.assertEqual(audit_args[4]["next_status"], "review")
async def test_teacher_clones_approved_persona_into_new_draft_version(self) -> None:
author_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000501"
conn = _PersonaCardConn(
[
_card_row(
persona_service.P2,
persona_id=persona_id,
status="approved",
version=1,
),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
record = await persona_repository.create_persona_revision_from_existing(
persona_id=persona_id,
author_id=author_id,
role="teacher",
)
self.assertIsNotNone(record)
assert record is not None
self.assertEqual(record.review.persona_id, persona_id)
self.assertEqual(record.review.code, "P2")
self.assertEqual(record.review.version, 2)
self.assertEqual(record.review.status, "draft")
self.assertEqual(record.card.display_name, persona_service.P2.display_name)
self.assertEqual(conn.fetchval_calls[0][1], ("P2",))
_, audit_args = conn.execute_calls[0]
self.assertEqual(audit_args[1], "persona_revision_create")
self.assertEqual(audit_args[4]["version"], 2)
async def test_clone_existing_approved_persona_reuses_open_draft(self) -> None:
author_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000501"
conn = _PersonaCardConn(
[
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=1),
_card_row(persona_service.P2, persona_id=persona_id, status="draft", version=2),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
record = await persona_repository.create_persona_revision_from_existing(
persona_id=persona_id,
author_id=author_id,
role="admin",
)
self.assertIsNotNone(record)
assert record is not None
self.assertEqual(record.review.version, 2)
self.assertEqual(record.review.status, "draft")
self.assertEqual(conn.fetchval_calls, [])
self.assertEqual(conn.execute_calls, [])
async def test_teacher_updates_persona_draft_and_submits_review(self) -> None:
author_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000502"
@ -519,6 +665,153 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertTrue(args["submit_for_review"])
self.assertEqual(args["card"].code, "P2")
async def test_teacher_generates_persona_draft_from_masked_source_notes(self) -> None:
captured: list[Any] = []
evidence = [
personas.PersonaGenerationEvidence(
chunk_id=44,
source_id="persona_authoring_test",
score=0.88,
kb_kind="supervisor_pattern",
heading_path="상담 기록",
excerpt="내담자 연락처 [PHONE]. 최근 이별 뒤 관계 불안을 호소함.",
)
]
source_ref = personas.PersonaSourceDocumentResponse(
source_id="persona_authoring_test",
doc_id=9,
doc_uri="persona-authoring/test/inline.txt",
title="상담 기록",
source_kind="client_record",
kb_kind="diagnostic",
license_class="B",
external_llm_ok=True,
content_hash="hash",
chunk_count=1,
chunks_indexed=1,
embedded=True,
pii_entities_masked=["PHONE"],
)
async def fake_generate(req: Any) -> GenerateResponse:
captured.append(req)
return GenerateResponse(
text="",
model="test-model",
provider="test",
structured={
"draft": {
"code": "P8",
"display_name": "자료 기반 내담자",
"difficulty": "moderate",
"theory_target": ["humanistic"],
"demographics": {"age_band": "20s"},
"presenting": {"complaint": "관계 갈등"},
"history": {"precipitant": "최근 이별"},
"big5": {"O": 0.5, "C": 0.5, "E": 0.4, "A": 0.5, "N": 0.7},
"resistance": {
"base_resistance": 0.45,
"unlock_rate": 0.12,
"decay_floor": 0.05,
},
"speech_style": {"register": "polite"},
"affect_baseline": {
"negative_affect": 0.55,
"hopelessness": 0.2,
"anxiety": 0.5,
"suicide_ideation_stage": 1,
},
"ccd": {"core_belief": "나는 버려질 수 있다"},
"dsm5_dimensional": {"anxiety": 0.5},
"triggers": {
"sore_spots": ["버림받음"],
"forbidden": ["그냥 잊으라는 조언"],
"reaction": "말수가 줄어든다.",
},
"source_provenance": "masked client_record",
"is_synthetic": True,
},
"source_summary": "관계 상실 후 불안이 높아진 사례",
"warnings": ["임상팀 검수 필요"],
},
)
with (
patch.object(personas, "_register_persona_source_document", AsyncMock(return_value=source_ref)),
patch.object(personas, "_retrieve_persona_generation_evidence", AsyncMock(return_value=evidence)),
patch.object(personas.engine_client, "generate", fake_generate),
):
response = await personas.generate_persona_draft_route(
personas.PersonaDraftGenerateRequest(
source_text="내담자 휴대폰 010-1234-5678. 최근 이별 뒤 관계 불안을 호소함.",
source_kind="client_record",
code_hint="P8",
),
_principal(Role.TEACHER),
)
self.assertEqual(response.draft.code, "P8")
self.assertEqual(response.draft.triggers["sore_spots"], ["버림받음"])
self.assertEqual(response.source_summary, "관계 상실 후 불안이 높아진 사례")
self.assertEqual(response.pii_entities_masked, ["PHONE"])
self.assertEqual(response.source_references[0].source_id, "persona_authoring_test")
self.assertEqual(response.evidence_chunks[0].chunk_id, 44)
self.assertIn("RAG sources=persona_authoring_test", response.draft.source_provenance)
self.assertIn("chunks=44", response.draft.source_provenance)
self.assertEqual(len(captured), 1)
sent_text = captured[0].messages[-1].content
self.assertNotIn("010-1234-5678", sent_text)
self.assertIn("[PHONE]", sent_text)
self.assertIn("RAG 근거 청크", sent_text)
async def test_teacher_registers_persona_source_as_evaluator_only_kb_document(self) -> None:
conn = _PersonaCardConn([])
captured_index: list[Any] = []
async def fake_index_document(_conn: Any, req: Any) -> Any:
captured_index.append(req)
return personas.rag.IndexResult(
doc_id=42,
chunks_indexed=len(req.chunks),
skipped_unchanged=False,
embedded=False,
degraded=True,
)
with (
patch.object(personas, "acquire", lambda **_: _Acquire(conn)),
patch.object(personas.rag, "index_document", fake_index_document),
):
response = await personas.create_persona_source_route(
personas.PersonaSourceDocumentRequest(
filename="case-note.txt",
source_kind="client_record",
text=(
"내담자 전화번호 010-1234-5678. 관계 단절 이후 불안을 호소함.\n\n"
"상담 장면에서는 조언을 들으면 침묵이 늘어남."
),
),
_principal(Role.TEACHER),
)
self.assertEqual(response.doc_id, 42)
self.assertEqual(response.source_kind, "client_record")
self.assertEqual(response.kb_kind, "diagnostic")
self.assertEqual(response.license_class, "B")
self.assertTrue(response.external_llm_ok)
self.assertEqual(response.pii_entities_masked, ["PHONE"])
self.assertEqual(len(captured_index), 1)
index_req = captured_index[0]
self.assertTrue(index_req.source_id.startswith("persona_authoring_"))
self.assertNotIn("010-1234-5678", index_req.chunks[0]["chunk_text"])
self.assertIn("[PHONE]", index_req.chunks[0]["chunk_text"])
self.assertEqual(index_req.chunks[0]["visible_to"], ["evaluator"])
self.assertEqual(index_req.chunks[0]["sensitivity"], 2)
self.assertEqual(index_req.chunks[0]["meta"]["source_kind"], "client_record")
source_query, source_args = conn.execute_calls[0]
self.assertIn("INSERT INTO kb.source", source_query)
self.assertEqual(source_args[2], "diagnostic")
async def test_learner_cannot_create_persona_draft_route(self) -> None:
with patch.object(
personas,
@ -681,6 +974,97 @@ class PersonaReviewQueueTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(audit_args[1], "persona_reject")
self.assertEqual(audit_args[4]["next_status"], "draft")
async def test_archive_persona_family_archives_all_versions_and_audits(self) -> None:
archiver_id = "00000000-0000-0000-0000-000000000901"
persona_id = "00000000-0000-0000-0000-000000000501"
conn = _PersonaCardConn(
[
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=1),
_card_row(persona_service.P2, persona_id=persona_id, status="approved", version=2),
_card_row(
persona_service.P3,
persona_id="00000000-0000-0000-0000-000000000503",
status="approved",
version=1,
),
]
)
with (
patch.object(persona_repository, "get_pool", return_value=object()),
patch.object(persona_repository, "acquire", lambda **_: _Acquire(conn)),
):
archived = await persona_repository.archive_persona_family(
persona_id=persona_id,
archiver_id=archiver_id,
role="teacher",
)
catalog = await persona_repository.list_approved_personas()
self.assertIsNotNone(archived)
assert archived is not None
self.assertEqual(archived.status, "archived")
self.assertTrue(
all(row["status"] == "archived" for row in conn.rows if str(row["code"]).upper() == "P2")
)
self.assertEqual([entry.card.code for entry in catalog], ["P3"])
_, audit_args = conn.execute_calls[0]
self.assertEqual(audit_args[1], "persona_archive")
self.assertEqual(audit_args[4]["scope"], "code_family")
async def test_archive_route_blocks_learner_before_repository_access(self) -> None:
with patch.object(
personas,
"archive_persona_family",
AsyncMock(side_effect=AssertionError("learner must not archive personas")),
) as archive:
with self.assertRaises(HTTPException) as caught:
await personas.archive_persona_route(
"00000000-0000-0000-0000-000000000501",
_principal(Role.LEARNER),
)
self.assertEqual(caught.exception.status_code, 403)
archive.assert_not_awaited()
async def test_teacher_revision_route_returns_editable_draft_detail(self) -> None:
record = PersonaDraftRecord(
review=PersonaReviewItem(
persona_id="00000000-0000-0000-0000-000000000503",
code="P3",
version=2,
status="draft",
display_name=persona_service.P3.display_name,
difficulty=persona_service.P3.difficulty,
theory_target=list(persona_service.P3.theory_target),
source_provenance=persona_service.P3.source_provenance,
is_synthetic=persona_service.P3.is_synthetic,
created_at="2026-01-04T00:00:00",
approved_at=None,
),
card=persona_service.P3,
)
with patch.object(
personas,
"create_persona_revision_from_existing",
AsyncMock(return_value=record),
) as create_revision:
response = await personas.create_persona_revision_route(
"00000000-0000-0000-0000-000000000503",
personas.PersonaRevisionRequest(),
_principal(Role.TEACHER),
)
self.assertEqual(response.status, "draft")
self.assertEqual(response.presenting, persona_service.P3.presenting)
create_revision.assert_awaited_once_with(
persona_id="00000000-0000-0000-0000-000000000503",
author_id="00000000-0000-0000-0000-000000000901",
role="teacher",
submit_for_review=False,
)
async def test_review_update_ignores_already_approved_persona(self) -> None:
conn = _PersonaCardConn(
[

View file

@ -258,6 +258,67 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
)
self.assertNotIn("SECRET_EVALUATOR_ONLY_REVIEW", rendered)
async def test_teacher_can_read_session_review_but_not_save_learner_worksheet(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000114",
)
teacher = _principal(
user_id="00000000-0000-0000-0000-000000000901",
role=Role.TEACHER,
)
sess = _session(
session_id="00000000-0000-0000-0000-00000000e114",
learner_id=owner.user_id,
)
sess.ended = True
sess.ended_at = 1_800_000_120.0
sess.turns.extend(
[
_turn(seq=1, speaker="counselor", text="teacher review visible learner turn"),
_turn(seq=2, speaker="client", text="teacher review visible client turn"),
]
)
with (
patch.object(
sessions.session_persistence,
"load_session",
AsyncMock(return_value=sess),
) as load_session,
patch.object(
sessions.session_persistence,
"load_session_evaluation",
AsyncMock(return_value=(None, False)),
),
patch.object(
sessions.session_persistence,
"load_case_worksheet",
AsyncMock(return_value=(None, False)),
),
):
response = await sessions.get_session_review(sess.session_id, teacher)
load_session.assert_awaited_once_with(
sess.session_id,
teacher,
allow_ended=True,
include_turn_evaluation=True,
)
self.assertEqual(response.session_id, sess.session_id)
self.assertEqual(
[turn.text for turn in response.turns],
["teacher review visible learner turn", "teacher review visible client turn"],
)
with self.assertRaises(HTTPException) as caught:
await sessions.save_session_review_worksheet(
sess.session_id,
sessions.ReviewCaseWorksheetSaveRequest(sections=[], limitations=[]),
teacher,
)
self.assertEqual(caught.exception.status_code, 403)
async def test_submit_turn_sends_only_client_visible_history_to_engine(self) -> None:
owner = _principal(
user_id="00000000-0000-0000-0000-000000000113",
@ -338,6 +399,11 @@ class LearnerSessionIdorTest(unittest.IsolatedAsyncioTestCase):
"list_sessions",
AsyncMock(return_value=([], False)),
),
patch.object(
sessions.session_persistence,
"list_session_archives",
AsyncMock(return_value=({}, False)),
),
patch.object(sessions, "require_runtime_fallback_allowed", return_value=None),
patch.object(sessions, "_review_ready", AsyncMock(return_value=False)),
):

View file

@ -134,7 +134,50 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(prefs.voice_preset_id, "soft-young-fem")
async def test_staging_blocks_admin_engine_config_default_when_row_missing(self) -> None:
async def test_prod_blocks_runtime_schema_bootstrap_ddl_when_schema_incomplete(self) -> None:
class IncompleteConn:
async def fetchrow(self, *args, **kwargs):
return {
"has_user_columns": False,
"has_persona_triggers": False,
"has_persona_voice_map": False,
"has_auth_session": False,
"has_preferences": False,
"has_engine_config": False,
"has_session_columns": False,
"has_state_columns": False,
"has_stage_defs": False,
"has_admin_health_event": False,
"has_support_ticket": False,
"has_admin_health_event_policies": False,
"has_support_ticket_policies": False,
"has_session_write_policies": False,
"removed_old_session_policy": False,
"has_turn_write_policies": False,
"removed_old_turn_policy": False,
}
async def execute(self, *args, **kwargs):
raise AssertionError("prod startup must not run owner DDL")
class IncompleteAcquire:
async def __aenter__(self):
return IncompleteConn()
async def __aexit__(self, exc_type, exc, tb):
return None
class IncompletePool:
def acquire(self):
return IncompleteAcquire()
with environment("prod"), patch.object(auth_sessions, "get_pool", return_value=IncompletePool()):
with self.assertRaises(RuntimeError) as caught:
await auth_sessions.ensure_runtime_tables()
self.assertIn("runtime DB schema is incomplete", str(caught.exception))
async def test_staging_uses_env_engine_config_when_row_missing(self) -> None:
class EmptyConfigConn:
async def fetchrow(self, *args, **kwargs):
return None
@ -151,6 +194,19 @@ class RuntimeFallbackPolicyTest(unittest.IsolatedAsyncioTestCase):
return EmptyConfigAcquire()
with environment("staging"), patch.object(admin_routes, "get_pool", return_value=EmptyConfigPool()):
config = await admin_routes._current_engine_config()
self.assertFalse(config.durable)
self.assertEqual(config.source, "runtime_default")
self.assertEqual(config.engine_mode, settings.engine_mode)
self.assertEqual(config.engine_url, settings.engine_url)
async def test_staging_blocks_admin_engine_config_when_db_unavailable(self) -> None:
class BrokenConfigPool:
def acquire(self):
raise RuntimeError("db unavailable")
with environment("staging"), patch.object(admin_routes, "get_pool", return_value=BrokenConfigPool()):
with self.assertRaises(HTTPException) as caught:
await admin_routes._current_engine_config()

View file

@ -0,0 +1,124 @@
"""Session review public share regression tests."""
from __future__ import annotations
import unittest
from starlette.requests import Request
from . import session_persistence
from .deps import Principal, Role
from .routes import sessions
from .routes import share as share_routes
from .services import persona as persona_service, state_machine
from .store import InProcSession, TurnRecord, store
def _principal() -> Principal:
return Principal(
user_id="00000000-0000-0000-0000-000000000201",
role=Role.LEARNER,
cohort_ids=[],
email="share-test@hs.ac.kr",
display_name="Share Test",
consent_at=1.0,
)
def _request(path: str = "/sessions/share-test-session/share") -> Request:
return Request(
{
"type": "http",
"method": "POST",
"path": path,
"scheme": "https",
"server": ("api.test", 443),
"headers": [(b"host", b"api.test")],
}
)
def _ended_session(principal: Principal) -> InProcSession:
card = persona_service.P1
sess = InProcSession(
session_id="share-test-session",
case_id="share-test-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(),
),
ended=True,
ended_at=1_800_000_100,
)
sess.turns.extend(
[
TurnRecord(
turn_seq=1,
speaker="counselor",
stage="라포",
text="원문 학습자 민감 발화",
text_masked="원문 학습자 민감 발화",
created_at=1_800_000_000,
),
TurnRecord(
turn_seq=2,
speaker="client",
stage="라포",
text="내담자 응답",
text_masked="내담자 응답",
created_at=1_800_000_030,
),
]
)
store.put(sess)
return sess
class SessionShareTest(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self) -> None:
store._sessions.clear()
session_persistence._SESSION_SHARE_CACHE.clear()
session_persistence._SESSION_SHARE_TOKEN_INDEX.clear()
async def asyncTearDown(self) -> None:
store._sessions.clear()
session_persistence._SESSION_SHARE_CACHE.clear()
session_persistence._SESSION_SHARE_TOKEN_INDEX.clear()
async def test_create_share_returns_public_summary_without_transcript(self) -> None:
principal = _principal()
_ended_session(principal)
response = await sessions.create_session_share(
"share-test-session",
_request(),
principal,
)
self.assertTrue(response.shareUrl.startswith("https://api.test/share/session/"))
token = response.shareUrl.rsplit("/", 1)[-1]
summary = await share_routes._load_share_or_404(token)
self.assertIn("Vignette 회기 리뷰", summary.title)
self.assertNotIn("원문 학습자 민감 발화", summary.model_dump_json())
self.assertIn("저장된 실제 축어록 2개", summary.summary)
async def test_revoke_share_blocks_public_lookup(self) -> None:
principal = _principal()
_ended_session(principal)
response = await sessions.create_session_share(
"share-test-session",
_request(),
principal,
)
token = response.shareUrl.rsplit("/", 1)[-1]
revoked = await sessions.revoke_session_share("share-test-session", principal)
self.assertTrue(revoked.revoked)
with self.assertRaises(share_routes.HTTPException) as caught:
await share_routes._load_share_or_404(token)
self.assertEqual(caught.exception.status_code, 404)

View file

@ -12,7 +12,7 @@ from .deps import Principal, Role
from .engine_client import EngineError
from .routes import sessions
from .routes import voice as voice_routes
from .services import memory, orchestrator, persona as persona_service, state_machine
from .services import live_coach, memory, orchestrator, persona as persona_service, state_machine
from .services.voice import TTSChunk, TranscriptResult, VoicePreset
from .store import InProcSession, TurnRecord, store
@ -25,6 +25,7 @@ def _principal() -> Principal:
email="turn-test@hs.ac.kr",
display_name="Turn Test",
consent_at=1.0,
profile_completed_at=1.0,
)
@ -310,6 +311,79 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertIn("조금 말해볼게요", learner_turn.evaluation["appropriateness_note"])
self.assertIsNone(client_turn.evaluation)
async def test_live_coach_degrades_to_rule_based_suggestion_when_engine_fails(self) -> None:
principal = _principal()
sess = _session(principal)
sess.turns.append(
TurnRecord(
turn_seq=1,
speaker="counselor",
stage=sess.state.stage.value,
text="그냥 학교는 가야 하는 거 아닐까요?",
text_masked="그냥 학교는 가야 하는 거 아닐까요?",
evaluation={"appropriateness": "warn", "appropriateness_note": "조언이 빠름"},
)
)
with patch.object(
sessions,
"_retrieve_live_coach_grounding",
AsyncMock(return_value=[]),
), patch.object(
sessions.engine_client,
"generate",
AsyncMock(side_effect=EngineError("offline")),
):
response = await sessions.live_coach_turn(
sess.session_id,
sessions.LiveCoachRequest(
learner_text="그냥 학교는 가야 하는 거 아닐까요?",
client_reply="몰라요. 그런 말 들으려고 온 건 아닌데요.",
turn_seq=1,
),
principal,
)
self.assertEqual(response.status, "degraded")
self.assertEqual(response.tone, "warn")
self.assertEqual(response.focus, "rapport")
self.assertIn("조언", response.title + response.message)
self.assertTrue(response.next_utterance)
self.assertTrue(response.sources)
self.assertEqual(response.sources[0].source_id, "workbook_0615_case_conceptualization")
history = await sessions.list_live_coach_history(sess.session_id, principal)
self.assertEqual(history.source, "runtime")
self.assertEqual(len(history.events), 1)
self.assertEqual(history.events[0].turn_seq, 1)
self.assertEqual(history.events[0].suggestion.title, response.title)
self.assertIn("학교", history.events[0].learner_text_excerpt or "")
async def test_live_coach_uses_official_risk_reference_pack_for_crisis_signal(self) -> None:
item = live_coach.LiveCoachInput(
session_id="risk-coach-session",
turn_seq=3,
stage="exploration",
effective_openness=0.45,
theory_mode="humanistic",
persona_code="P1",
persona_name="서연",
learner_text="죽고 싶다는 생각이 들 때도 있나요?",
client_reply="가끔 그런 생각이 들어요.",
recent_turns=[],
evaluation={"appropriateness": "warn", "appropriateness_note": "위험사정 필요"},
)
engine = SimpleNamespace(generate=AsyncMock(side_effect=EngineError("offline")))
suggestion = await live_coach.generate_live_coaching(item, engine=engine)
self.assertEqual(suggestion.focus, "risk")
source_ids = [source.source_id for source in suggestion.sources]
self.assertIn("official_suicide_risk_guidelines", source_ids)
official = next(source for source in suggestion.sources if source.source_id == "official_suicide_risk_guidelines")
self.assertEqual(official.source_type, "official_guideline")
self.assertTrue(official.citation)
engine.generate.assert_called_once()
async def test_start_session_uses_stable_case_context_and_seed_recall(self) -> None:
principal = _principal()
card = persona_service.P1
@ -394,6 +468,25 @@ class SessionTurnPersistenceTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(caught.exception.detail, "consent_required")
get_persona.assert_not_awaited()
async def test_start_session_requires_onboarding_before_consent_and_catalog_lookup(self) -> None:
principal = _principal()
principal.profile_completed_at = None
with patch.object(
sessions,
"get_catalog_persona",
AsyncMock(side_effect=AssertionError("onboarding gate must run before catalog lookup")),
) as get_persona:
with self.assertRaises(sessions.HTTPException) as caught:
await sessions.start_session(
sessions.SessionStartRequest(persona_code=persona_service.P1.code),
principal,
)
self.assertEqual(caught.exception.status_code, 403)
self.assertEqual(caught.exception.detail, "onboarding_required")
get_persona.assert_not_awaited()
async def test_run_turn_stream_parses_gateway_done_telemetry(self) -> None:
class FakeStreamEngine:
engine_mode = "claude_cli"

View file

@ -103,6 +103,11 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
"list_safety_alerts",
AsyncMock(return_value=([], True)),
),
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(return_value=({}, True)),
),
):
response = await teacher.teacher_dashboard(principal)
@ -118,6 +123,115 @@ class TeacherDashboardGrowthTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(growth.trend, "up")
self.assertEqual(growth.top_techniques, ["reflection"])
self.assertEqual([point.session_no for point in growth.points], [1, 2])
self.assertEqual(len(response.pending_reviews), 2)
async def test_dashboard_excludes_closed_session_reviews_from_pending_queue(self) -> None:
learner_id = "00000000-0000-0000-0000-000000000111"
open_session = _session(
session_id="00000000-0000-0000-0000-00000000b111",
session_no=1,
learner_id=learner_id,
score="neutral",
rapport=0.1,
technique="reflection",
created_at=1_000.0,
)
closed_session = _session(
session_id="00000000-0000-0000-0000-00000000b112",
session_no=2,
learner_id=learner_id,
score="pos",
rapport=0.5,
technique="reflection",
created_at=2_000.0,
)
principal = _principal()
with (
patch.object(
teacher.session_persistence,
"list_sessions",
AsyncMock(return_value=([open_session, closed_session], True)),
),
patch.object(
teacher.session_persistence,
"list_safety_alerts",
AsyncMock(return_value=([], True)),
),
patch.object(
teacher.session_persistence,
"list_session_review_statuses",
AsyncMock(
return_value=(
{
closed_session.session_id: {
"session_id": closed_session.session_id,
"status": "closed",
"note": "확인 완료",
"reviewed_at": "2026-06-27T10:00:00Z",
}
},
True,
)
),
),
):
response = await teacher.teacher_dashboard(principal)
self.assertEqual([item.session_id for item in response.pending_reviews], [open_session.session_id])
closed_summary = next(
item for item in response.recent_sessions if item.session_id == closed_session.session_id
)
self.assertEqual(closed_summary.review_status, "closed")
async def test_teacher_can_mark_session_review_closed_with_note(self) -> None:
principal = _principal()
sess = _session(
session_id="00000000-0000-0000-0000-00000000c111",
session_no=1,
learner_id="00000000-0000-0000-0000-000000000222",
score="pos",
rapport=0.5,
technique="reflection",
created_at=3_000.0,
)
with (
patch.object(
teacher.session_persistence,
"load_session",
AsyncMock(return_value=sess),
),
patch.object(
teacher.session_persistence,
"save_session_review_status",
AsyncMock(
return_value=(
{
"session_id": sess.session_id,
"reviewer_id": principal.user_id,
"status": "closed",
"note": "다음 회기에서 반영 질문을 늘리도록 지도",
"reviewed_at": "2026-06-27T10:00:00Z",
"updated_at": "2026-06-27T10:00:00Z",
},
True,
)
),
) as save_status,
):
response = await teacher.update_session_review_status(
sess.session_id,
teacher.TeacherSessionReviewStatusRequest(
status="closed",
note="다음 회기에서 반영 질문을 늘리도록 지도",
),
principal,
)
save_status.assert_awaited_once()
self.assertEqual(response.status, "closed")
self.assertIn("반영 질문", response.note)
if __name__ == "__main__":

View file

@ -17,6 +17,7 @@ from .services.voice import (
assess_end_of_turn,
build_tts_payload,
resolve_voice,
resolve_voice_from_map,
)
@ -50,6 +51,49 @@ class VoicePresetResolutionTest(unittest.TestCase):
self.assertEqual(voice.openai_voice, DEFAULT_OPENAI_VOICE)
self.assertEqual(voice.rate, 1.0)
def test_openai_persona_voice_map_overrides_live_tts_fields(self) -> None:
voice = resolve_voice_from_map(
provider="openai",
voice_id="voice-p1-custom",
persona_code="P1",
base_params={
"preset": "soft-young-fem",
"openai_voice": "nova",
"rate": 1.14,
"instructions": "Keep the voice quiet and hesitant.",
},
)
self.assertIsNotNone(voice)
assert voice is not None
self.assertEqual(voice.preset, "soft-young-fem")
self.assertEqual(voice.openai_voice, "nova")
self.assertAlmostEqual(voice.rate, 1.14)
self.assertEqual(voice.instructions, "Keep the voice quiet and hesitant.")
def test_openai_persona_voice_map_can_use_voice_id_as_openai_voice(self) -> None:
voice = resolve_voice_from_map(
provider="openai",
voice_id="verse",
persona_code="P2",
base_params={"preset": "calm-adult-male"},
)
self.assertIsNotNone(voice)
assert voice is not None
self.assertEqual(voice.preset, "calm-adult-male")
self.assertEqual(voice.openai_voice, "verse")
def test_non_openai_persona_voice_map_returns_none_for_safe_fallback(self) -> None:
self.assertIsNone(
resolve_voice_from_map(
provider="higgs",
voice_id="p1-synthetic",
persona_code="P1",
base_params={"openai_voice": "coral"},
)
)
class TTSPayloadTest(unittest.TestCase):
def test_payload_contains_openai_tts_fields_and_clamps_high_speed(self) -> None:

View file

@ -4,9 +4,11 @@ from __future__ import annotations
import json
import unittest
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
from .deps import Principal, Role
from .persona_repository import PersonaVoiceMap
from .routes import voice as voice_routes
from .services.voice import VoicePreset
@ -289,6 +291,95 @@ class VoiceWebSocketContractTest(unittest.IsolatedAsyncioTestCase):
bind_session.assert_not_awaited()
is_available.assert_not_called()
async def test_bind_session_uses_db_voice_map_for_existing_session(self) -> None:
websocket = FakeWebSocket()
websocket.query_params = {"session_id": SESSION_ID}
sess = SimpleNamespace(persona=SimpleNamespace(code="P2"))
voice_map = PersonaVoiceMap(
provider="openai",
voice_id="voice-p2-custom",
base_params={
"preset": "calm-adult-male",
"openai_voice": "onyx",
"rate": 1.08,
"instructions": "Low, guarded delivery.",
},
)
get_voice_map = AsyncMock(return_value=voice_map)
with patch.object(
voice_routes,
"_load_voice_session",
AsyncMock(return_value=(sess, None)),
), patch.object(
voice_routes,
"get_session_voice_map",
get_voice_map,
):
session_id, voice, err, meta = await voice_routes._bind_session(
websocket, _principal()
)
self.assertEqual(session_id, SESSION_ID)
self.assertIsNone(err)
self.assertEqual(meta["persona_catalog_source"], "session")
self.assertEqual(voice, VoicePreset(
preset="calm-adult-male",
openai_voice="onyx",
rate=1.08,
instructions="Low, guarded delivery.",
))
get_voice_map.assert_awaited_once_with(SESSION_ID)
async def test_bind_session_explicit_preset_overrides_db_voice_map(self) -> None:
websocket = FakeWebSocket()
websocket.query_params = {"session_id": SESSION_ID, "preset": "soft-young-fem"}
sess = SimpleNamespace(persona=SimpleNamespace(code="P2"))
get_voice_map = AsyncMock()
with patch.object(
voice_routes,
"_load_voice_session",
AsyncMock(return_value=(sess, None)),
), patch.object(
voice_routes,
"get_session_voice_map",
get_voice_map,
):
session_id, voice, err, _ = await voice_routes._bind_session(
websocket, _principal()
)
self.assertEqual(session_id, SESSION_ID)
self.assertIsNone(err)
self.assertEqual(voice.preset, "soft-young-fem")
self.assertEqual(voice.openai_voice, "coral")
get_voice_map.assert_not_awaited()
async def test_catalog_voice_map_is_used_for_dev_persona_binding_helper(self) -> None:
voice_map = PersonaVoiceMap(
provider="openai",
voice_id="verse",
base_params={"preset": "soft-young-fem", "rate": 0.9},
)
get_voice_map = AsyncMock(return_value=voice_map)
with patch.object(voice_routes, "get_persona_voice_map", get_voice_map):
voice = await voice_routes._resolve_catalog_voice(
persona_id="00000000-0000-0000-0000-000000000301",
version=7,
persona_code="P1",
explicit_preset=None,
)
self.assertEqual(voice.preset, "soft-young-fem")
self.assertEqual(voice.openai_voice, "verse")
self.assertAlmostEqual(voice.rate, 0.9)
get_voice_map.assert_awaited_once_with(
persona_id="00000000-0000-0000-0000-000000000301",
version=7,
)
if __name__ == "__main__":
unittest.main()

View file

@ -13,11 +13,24 @@ import json
import os
import time
import uuid
from typing import Any, Literal, Optional
from typing import Any, Optional
from fastapi import FastAPI, HTTPException
from fastapi.responses import JSONResponse, StreamingResponse
from pydantic import BaseModel, Field
from pydantic import BaseModel
from app.contracts.engine_gateway import (
AIRole,
ENGINE_GATEWAY_SSE_DONE,
ENGINE_GATEWAY_SSE_ERROR,
ENGINE_GATEWAY_SSE_TOKEN,
EngineMessage as GwMessage,
GenerateRequest as GwGenerateReq,
StreamDoneEvent,
StreamErrorEvent,
StreamTokenEvent,
sse_frame,
)
CLAUDE_BIN = os.environ.get("CLAUDE_BIN", "claude")
DEFAULT_MODEL = os.environ.get("ENGINE_MODEL", "") # 비우면 CLI 기본(Opus 4.8)
@ -321,26 +334,6 @@ async def close_session(sid: str):
# session_id 가 오면 풀을 재사용해 멀티턴 prompt caching 이점을 살린다.
# ════════════════════════════════════════════════════════════════════════════
AIRole = Literal["client", "counselor", "evaluator"]
class GwMessage(BaseModel):
role: Literal["system", "user", "assistant"]
content: str
cache: bool = False # 프롬프트 캐싱 힌트 (L0~L2 cache_control 대상)
class GwGenerateReq(BaseModel):
ai_role: AIRole = "client"
messages: list[GwMessage]
model: Optional[str] = None
max_tokens: int = 1024
temperature: float = 0.7
structured_schema: Optional[dict[str, Any]] = None
session_id: Optional[str] = None # 풀 재사용 키(있으면 멀티턴 캐시)
metadata: dict[str, Any] = Field(default_factory=dict)
def _split_messages(messages: list[GwMessage]) -> tuple[str, str]:
"""EngineMessage[] → (system_prompt, user_payload).
@ -452,28 +445,33 @@ async def v1_stream(req: GwGenerateReq):
try:
async for evt in s.turn_stream(user_payload, timeout=600.0):
if evt.get("type") == "delta":
payload = json.dumps({"text": evt["text"]}, ensure_ascii=False)
yield f"event: token\ndata: {payload}\n\n"
yield sse_frame(
ENGINE_GATEWAY_SSE_TOKEN,
StreamTokenEvent(text=evt["text"]),
)
elif evt.get("type") == "done":
if evt.get("is_error"):
err = json.dumps({"detail": str(evt.get("error"))}, ensure_ascii=False)
yield f"event: error\ndata: {err}\n\n"
else:
meta = json.dumps(
{
"provider": "claude_cli",
"model": s.model or DEFAULT_MODEL or "claude-opus-4-8",
"tokens_in": 0,
"tokens_out": 0,
"cost_usd": evt.get("cost_usd", 0.0),
"turns": evt.get("turns", 0),
},
ensure_ascii=False,
yield sse_frame(
ENGINE_GATEWAY_SSE_ERROR,
StreamErrorEvent(detail=str(evt.get("error"))),
)
else:
yield sse_frame(
ENGINE_GATEWAY_SSE_DONE,
StreamDoneEvent(
provider="claude_cli",
model=s.model or DEFAULT_MODEL or "claude-opus-4-8",
tokens_in=0,
tokens_out=0,
cost_usd=evt.get("cost_usd", 0.0),
turns=evt.get("turns", 0),
),
)
yield f"event: done\ndata: {meta}\n\n"
except Exception as e: # 전송 도중 실패도 SSE 프레임으로 알린다
err = json.dumps({"detail": str(e)}, ensure_ascii=False)
yield f"event: error\ndata: {err}\n\n"
yield sse_frame(
ENGINE_GATEWAY_SSE_ERROR,
StreamErrorEvent(detail=str(e)),
)
finally:
if ephemeral:
await s.close()

View file

@ -2,6 +2,8 @@ import asyncio
import unittest
from unittest.mock import patch
from app import engine_client
from app.contracts import engine_gateway as contract
from engine_gateway import gateway
@ -86,6 +88,22 @@ class GatewayModelTest(unittest.TestCase):
def tearDown(self):
gateway.SESSIONS.clear()
def test_gateway_reuses_shared_engine_contract_models(self):
self.assertIs(gateway.GwGenerateReq, contract.GenerateRequest)
self.assertIs(gateway.GwMessage, contract.EngineMessage)
self.assertIs(engine_client.GenerateRequest, contract.GenerateRequest)
self.assertEqual(contract.ENGINE_GATEWAY_SSE_EVENTS, ("token", "done", "error"))
def test_sse_frame_helper_preserves_gateway_wire_contract(self):
self.assertEqual(
contract.sse_frame("token", contract.StreamTokenEvent(text="hello")),
'event: token\ndata: {"text": "hello"}\n\n',
)
self.assertEqual(
contract.sse_frame("error", contract.StreamErrorEvent(detail="failed")),
'event: error\ndata: {"detail": "failed"}\n\n',
)
def test_resolve_session_uses_request_model_for_claude_cli(self):
captured, process_patch = _capture_subprocess()
with (

View file

@ -15,12 +15,12 @@
FlagEmbedding==1.2.11
# BGE-M3 백엔드(transformers/torch). FlagEmbedding 1.2.11 과 정합하는 transformers 핀.
torch>=2.1
torch==2.5.1
transformers==4.44.2
# (선택) sentence-transformers — 보조 임베딩/유틸. FlagEmbedding 만으로도 BGE-M3 동작.
sentence-transformers>=2.7
sentence-transformers==3.3.1
# DB pgvector 코덱(선택) — db.py 가 텍스트 캐스트($1::vector)로 우회하므로 미설치도 동작.
# 바이너리 코덱 등록(register_vector) 시 성능↑. 붙이면 rag._vector_literal 대신 list 직접 바인딩 가능.
pgvector>=0.2.5
pgvector==0.3.5

View file

@ -1,11 +1,13 @@
fastapi
uvicorn[standard]
asyncpg
pydantic
pydantic-settings
python-multipart
httpx
sse-starlette
# Direct runtime dependencies are pinned for transplantable Docker builds.
# Re-pin only after running the backend tests and compose smoke.
fastapi==0.111.0
uvicorn[standard]==0.30.6
asyncpg==0.31.0
pydantic==2.9.2
pydantic-settings==2.12.0
python-multipart==0.0.18
httpx==0.28.1
sse-starlette==3.0.3
# ── 선택 의존성 (가드레일 PII 마스킹) ─────────────────────────────
# Presidio 가 설치되면 guardrail.mask_pii 가 NER 기반으로 동작하고,

10
apps/web/.dockerignore Normal file
View file

@ -0,0 +1,10 @@
node_modules/
dist/
build/
playwright-report/
test-results/
node_modules/.tmp/
*.log
*.err
.env
.env.*

View file

@ -2,12 +2,12 @@
FROM node:22-alpine AS build
WORKDIR /app
# node 22 사용(23.x는 일부 빌드 segfault 이슈 회피)
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile || npm install
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ARG VITE_API_BASE=/api
ENV VITE_API_BASE=$VITE_API_BASE
RUN pnpm build || npm run build
RUN npm run build
# 정적 서빙(작고 이식성 높은 nginx)
FROM nginx:1.27-alpine

View file

@ -1,5 +1,6 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import {
completeOnboarding,
expectNoHorizontalOverflow,
signInAsLearner,
useRealApi,
@ -29,8 +30,12 @@ interface AdminManagedUser {
email: string;
display_name: string;
role: "learner" | "teacher" | "admin";
account_status: "pending" | "approved" | "suspended";
affiliation: string;
cohort_ids: string[];
active_sessions: number;
last_seen_at: number;
created_at: number;
}
interface AdminUsersResponse {
@ -68,6 +73,48 @@ interface AdminUsageResponse {
by_provider: AdminUsageBreakdown[];
}
interface AdminUptimeResponse {
source: "database" | "unavailable";
durable: boolean;
window_hours: number;
sample_count: number;
ok_ratio: number;
degraded_events: number;
down_events: number;
last_down_at: number | null;
}
interface AdminTicketReporter {
email: string;
display_name: string;
role: string;
}
interface AdminSupportTicket {
ticket_id: string;
reporter: AdminTicketReporter;
category: string;
priority: "low" | "normal" | "high" | "urgent";
status: "open" | "triaged" | "in_progress" | "resolved" | "closed";
subject: string;
body: string;
source_path: string;
created_at: number;
updated_at: number;
}
interface AdminTicketsResponse {
source: "database" | "unavailable";
durable: boolean;
tickets: AdminSupportTicket[];
summary: {
total: number;
open_count: number;
high_priority_count: number;
stale_count: number;
};
}
async function expectResponseOk(response: APIResponse | Response) {
if (!response.ok()) {
expect(response.ok(), await response.text()).toBeTruthy();
@ -75,34 +122,97 @@ async function expectResponseOk(response: APIResponse | Response) {
}
async function signInAsAdmin(page: Page) {
const res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
let res: APIResponse | null = null;
for (let attempt = 0; attempt < 3; attempt += 1) {
res = await page.request.post("/api/auth/dev-login", {
data: {
email: "admin@twentyoz.kr",
role: "admin",
display_name: "E2E Admin",
},
});
if (res.ok()) break;
await new Promise((resolve) => setTimeout(resolve, 300));
}
if (!res) throw new Error("admin dev-login did not return a response");
await expectResponseOk(res);
await completeOnboarding(page, {
legal_name: "E2E Admin",
affiliation: "한신대학교",
department: "운영",
grade_level: "관리자",
phone: "010-2222-2222",
contact_address: "경기도 오산시 한신대학교",
});
}
function isJsonResponse(response: Response) {
return response.headers()["content-type"]?.includes("application/json") ?? false;
}
function isAdminHealthResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/health");
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/health") &&
isJsonResponse(response)
);
}
function isAdminUsersResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/users");
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/users") &&
isJsonResponse(response)
);
}
function isAdminUsageResponse(response: Response) {
const url = new URL(response.url());
return response.request().method() === "GET" && url.pathname.endsWith("/admin/usage");
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/usage") &&
isJsonResponse(response)
);
}
function isAdminUptimeResponse(response: Response) {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/uptime") &&
isJsonResponse(response)
);
}
function isAdminTicketsResponse(response: Response) {
const url = new URL(response.url());
return (
response.request().method() === "GET" &&
url.pathname.endsWith("/admin/tickets") &&
isJsonResponse(response)
);
}
function isAdminTicketPatch(ticketId: string) {
return (response: Response) => {
const url = new URL(response.url());
return (
response.request().method() === "PATCH" &&
url.pathname.endsWith(`/admin/tickets/${ticketId}`) &&
isJsonResponse(response)
);
};
}
function isAdminUserCreate(response: Response) {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname.endsWith("/admin/users");
return (
response.request().method() === "POST" &&
url.pathname.endsWith("/admin/users") &&
isJsonResponse(response)
);
}
function isAdminUserPatch(userId: string) {
@ -110,7 +220,8 @@ function isAdminUserPatch(userId: string) {
const url = new URL(response.url());
return (
response.request().method() === "PATCH" &&
url.pathname.endsWith(`/admin/users/${userId}`)
url.pathname.endsWith(`/admin/users/${userId}`) &&
isJsonResponse(response)
);
};
}
@ -120,7 +231,8 @@ function isAdminUserDelete(userId: string) {
const url = new URL(response.url());
return (
response.request().method() === "DELETE" &&
url.pathname.endsWith(`/admin/users/${userId}`)
url.pathname.endsWith(`/admin/users/${userId}`) &&
isJsonResponse(response)
);
};
}
@ -150,35 +262,59 @@ function costLabel(value: number) {
return `$${value.toFixed(value < 0.01 ? 6 : 4)}`;
}
function isOnline(seconds: number) {
return Number.isFinite(seconds) && seconds > 0 && Date.now() / 1000 - seconds < 15 * 60;
}
async function openAdminAndReadHealth(page: Page) {
const healthResponsePromise = page.waitForResponse(isAdminHealthResponse);
const usageResponsePromise = page.waitForResponse(isAdminUsageResponse);
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
const uptimeResponsePromise = page.waitForResponse(isAdminUptimeResponse);
const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse);
await page.goto("/admin");
const [healthResponse, usageResponse] = await Promise.all([
const [healthResponse, usageResponse, usersResponse, uptimeResponse, ticketsResponse] = await Promise.all([
healthResponsePromise,
usageResponsePromise,
usersResponsePromise,
uptimeResponsePromise,
ticketsResponsePromise,
]);
await expectResponseOk(healthResponse);
await expectResponseOk(usageResponse);
await expectResponseOk(usersResponse);
await expectResponseOk(uptimeResponse);
await expectResponseOk(ticketsResponse);
const health = (await healthResponse.json()) as AdminHealthResponse;
const usage = (await usageResponse.json()) as AdminUsageResponse;
const users = (await usersResponse.json()) as AdminUsersResponse;
const uptime = (await uptimeResponse.json()) as AdminUptimeResponse;
const tickets = (await ticketsResponse.json()) as AdminTicketsResponse;
expect(health.services.length).toBeGreaterThan(0);
return { health, usage };
return { health, usage, users, uptime, tickets };
}
async function openAdminAndReadUsers(page: Page) {
const usersResponsePromise = page.waitForResponse(isAdminUsersResponse);
await page.goto("/admin");
await page.goto("/admin/users");
const usersResponse = await usersResponsePromise;
await expectResponseOk(usersResponse);
return (await usersResponse.json()) as AdminUsersResponse;
}
async function openAdminTickets(page: Page) {
const ticketsResponsePromise = page.waitForResponse(isAdminTicketsResponse);
await page.goto("/admin/tickets");
const ticketsResponse = await ticketsResponsePromise;
await expectResponseOk(ticketsResponse);
return (await ticketsResponse.json()) as AdminTicketsResponse;
}
async function expectCreateUserControlsFit(page: Page, viewportWidth: number) {
const form = page.locator(".ad-user-create");
await expect(form).toBeVisible();
@ -272,36 +408,36 @@ test.describe("admin route", () => {
});
test("allows an admin to open the live health dashboard", async ({ page }) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
await withGlobalEngineConfigLock("admin-health-dashboard", async () => {
const { health, usage } = await openAdminAndReadHealth(page);
const { health, usage, users, uptime, tickets } = await openAdminAndReadHealth(page);
const serviceCards = page.locator(".ad-service:not(.ad-service--skeleton)");
const counts = {
ok: health.services.filter((service) => service.status === "ok").length,
degraded: health.services.filter((service) => service.status === "degraded").length,
down: health.services.filter((service) => service.status === "down").length,
total: health.services.length,
};
const activeSessions = users.users.reduce(
(sum, user) => sum + Math.max(0, user.active_sessions),
0,
);
const onlineUsers = users.users.filter((user) => isOnline(user.last_seen_at)).length;
await expect(page).toHaveURL(/\/admin$/);
await expect(page.locator(".ad-status")).toContainText(environmentLabel(health.environment));
await expect(page.locator(".ad-status")).toContainText(engineModeLabel(health.engine_mode));
await expect(page.locator(".ad-kpi b")).toHaveText([
String(health.services.length),
String(counts.ok),
String(counts.degraded),
String(counts.down),
countLabel(activeSessions),
countLabel(onlineUsers),
countLabel(users.users.length),
counts.total ? `${counts.ok}/${counts.total}` : "-",
]);
await expect(page.getByRole("heading", { name: "AI 비용 관측" })).toBeVisible();
await expect(page.locator(".ad-usage-kpi b")).toHaveText([
costLabel(usage.cost_usd),
countLabel(usage.tokens_in),
countLabel(usage.tokens_out),
usage.total_turns > 0
? `${Math.round((usage.metered_turns / usage.total_turns) * 100)}%`
: "0%",
]);
await expect(page.locator(".ad-usage-budget")).toContainText(
await expect(page.getByRole("heading", { name: "AI 비용" })).toBeVisible();
await expect(page.locator(".ad-cost")).toContainText(costLabel(usage.cost_usd));
await expect(page.locator(".ad-cost")).toContainText(
usage.budget.status === "disabled"
? "예산 경고 비활성"
: usage.budget.status === "exceeded"
@ -310,16 +446,15 @@ test.describe("admin route", () => {
? "예산 주의"
: "예산 정상",
);
if (usage.by_provider.length > 0) {
await expect(page.locator(".ad-usage-row")).toHaveCount(usage.by_provider.length);
await expect(page.locator(".ad-usage-row").first()).toContainText(
usage.by_provider[0].provider,
);
} else {
await expect(page.locator(".ad-usage-breakdown")).toContainText(
"최근 윈도우에 계량된 AI 턴이 없습니다.",
);
}
await expect(page.getByRole("heading", { name: "가용성" })).toBeVisible();
await expect(page.getByText("샘플 정상률")).toBeVisible();
expect(uptime.ok_ratio).toBeGreaterThanOrEqual(0);
expect(uptime.ok_ratio).toBeLessThanOrEqual(1);
await expect(page.getByRole("heading", { name: "운영 티켓" })).toBeVisible();
expect(tickets.summary.open_count).toBeGreaterThanOrEqual(0);
await expect(page.locator(".ad-panel").filter({ hasText: "운영 티켓" })).toContainText(
/(\d+건 미해결|미해결 티켓이 없습니다)/,
);
await expect(serviceCards).toHaveCount(health.services.length);
for (const service of health.services) {
@ -357,6 +492,7 @@ test.describe("admin route", () => {
});
test("allows an admin to manage real server-known users", async ({ page }, testInfo) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
@ -366,14 +502,17 @@ test.describe("admin route", () => {
expect(users.source).toBe("database");
expect(users.durable).toBe(true);
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(`no-match-${slug}`);
await expect(page.getByText("검색 조건에 맞는 사용자가 없습니다.")).toBeVisible();
await expect(page.getByText("아직 등록된 사용자가 없습니다.")).toHaveCount(0);
await page.getByLabel("사용자 검색").fill("");
await page.getByRole("tab", { name: "사용자 등록" }).click();
await page.getByLabel("새 사용자 이메일").fill(email);
await page.getByLabel("새 사용자 표시 이름").fill(displayName);
await page.getByLabel("새 사용자 역할").selectOption("learner");
await page.getByLabel("새 사용자 승인 상태").selectOption("pending");
await page.getByLabel("새 사용자 코호트").fill(`created-${testInfo.project.name}`);
const createPromise = page.waitForResponse(isAdminUserCreate);
@ -387,13 +526,40 @@ test.describe("admin route", () => {
email,
display_name: displayName,
role: "learner",
account_status: "pending",
cohort_ids: [`created-${testInfo.project.name}`],
});
await expect(page.getByRole("tab", { name: "사용자 목록" })).toHaveAttribute(
"aria-selected",
"true",
);
const approvalTab = page.getByRole("tab", { name: /가입 승인/ });
await approvalTab.click();
await expect(approvalTab).toHaveAttribute("aria-selected", "true");
const approvalCard = page.locator(".ad-approval").filter({ hasText: email });
await expect(approvalCard).toBeVisible();
await expect(approvalCard).toContainText("승인 대기");
await expectVisibleButtonsFit(page, ".ad-approval__actions .vg-btn", "admin approval buttons");
const approvePromise = page.waitForResponse(isAdminUserPatch(created.user_id));
await approvalCard.getByRole("button", { name: "승인" }).click();
const approveResponse = await approvePromise;
await expectResponseOk(approveResponse);
const approved = (await approveResponse.json()) as AdminManagedUser;
expect(approved).toMatchObject({
user_id: created.user_id,
email,
account_status: "approved",
});
await expect(approvalCard).toHaveCount(0);
await page.getByRole("tab", { name: "사용자 목록" }).click();
await page.getByLabel("사용자 검색").fill(email);
const card = page.locator(".ad-user").filter({ hasText: email });
await expect(card).toBeVisible();
await expect(card).toContainText(displayName);
await expect(card).toContainText("승인됨");
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "admin user action buttons");
const nextName = `교수자 ${testInfo.project.name}`;
@ -422,27 +588,73 @@ test.describe("admin route", () => {
email,
display_name: nextName,
role: "teacher",
account_status: "approved",
cohort_ids: [nextCohort],
});
await expect(card).toContainText(nextName);
await expect(card).toContainText("교수자");
await expect(card).toContainText(nextCohort);
await expect(cohortInput).toHaveValue(nextCohort);
const deletePromise = page.waitForResponse(isAdminUserDelete(created.user_id));
await card.getByRole("button", { name: "비활성화" }).click();
const deleteResponse = await deletePromise;
await expectResponseOk(deleteResponse);
await expect(card).toHaveCount(0);
});
const blockedLogin = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "teacher",
display_name: nextName,
},
});
expect(blockedLogin.status(), await blockedLogin.text()).toBe(403);
test("shows and resolves user-submitted operation tickets", async ({ page }, testInfo) => {
await signInAsAdmin(page);
const slug = `${testInfo.project.name}.${testInfo.workerIndex}.${testInfo.retry}.${Date.now()}`;
const subject = `[E2E] 운영 티켓 ${slug}`;
let createdTicketId: string | null = null;
try {
const create = await page.request.post("/api/users/support-tickets", {
data: {
category: "session_review",
priority: "high",
subject,
body: "E2E 운영 티켓 처리 흐름 검증입니다. 실제 리뷰 생성 지연이 아닙니다.",
source_path: "/__e2e__/learn/session/test/review",
},
});
await expectResponseOk(create);
const created = (await create.json()) as { ticket_id: string };
createdTicketId = created.ticket_id;
const tickets = await openAdminTickets(page);
expect(tickets.source).toBe("database");
expect(tickets.durable).toBe(true);
expect(tickets.tickets.some((ticket) => ticket.ticket_id === created.ticket_id)).toBeTruthy();
const card = page.locator(".ad-ticket").filter({ hasText: subject });
await expect(card).toBeVisible();
await expect(card).toContainText("높음");
await expect(card).toContainText("미해결");
const patchPromise = page.waitForResponse(isAdminTicketPatch(created.ticket_id));
await card.getByRole("button", { name: "해결" }).click();
const patchResponse = await patchPromise;
await expectResponseOk(patchResponse);
const updated = (await patchResponse.json()) as AdminSupportTicket;
expect(updated.status).toBe("resolved");
await expect(card).toContainText("해결");
await expectNoHorizontalOverflow(page);
} finally {
if (createdTicketId) {
const cleanup = await page.request.patch(`/api/admin/tickets/${createdTicketId}`, {
data: {
status: "resolved",
resolution_note: "E2E cleanup",
},
});
if (!cleanup.ok()) {
console.warn(`E2E ticket cleanup failed: ${cleanup.status()} ${await cleanup.text()}`);
}
}
}
});
test("denies learner access to the admin API and UI", async ({ page }) => {
@ -452,6 +664,10 @@ test.describe("admin route", () => {
expect(denied.status(), await denied.text()).toBe(403);
const deniedUsers = await page.request.get("/api/admin/users");
expect(deniedUsers.status(), await deniedUsers.text()).toBe(403);
const deniedUptime = await page.request.get("/api/admin/uptime");
expect(deniedUptime.status(), await deniedUptime.text()).toBe(403);
const deniedTickets = await page.request.get("/api/admin/tickets");
expect(deniedTickets.status(), await deniedTickets.text()).toBe(403);
await page.goto("/admin");
@ -460,35 +676,37 @@ test.describe("admin route", () => {
});
test("keeps admin controls usable at a mobile viewport", async ({ page }) => {
test.setTimeout(60_000);
await page.setViewportSize({ width: 390, height: 844 });
await signInAsAdmin(page);
const users = await openAdminAndReadUsers(page);
await page.getByRole("tab", { name: "사용자 등록" }).click();
const layout = await page.evaluate(() => {
const workspace = document.querySelector<HTMLElement>(".ad-user-workspace");
const form = document.querySelector<HTMLElement>(".ad-user-create");
if (!workspace || !form) throw new Error("admin user workspace was not rendered");
if (!form) throw new Error("admin user create form was not rendered");
return {
workspaceColumns: window.getComputedStyle(workspace).gridTemplateColumns.split(" ").length,
formColumns: window.getComputedStyle(form).gridTemplateColumns.split(" ").length,
};
});
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, 390);
expect(layout.workspaceColumns).toBe(1);
expect(layout.formColumns).toBe(1);
if (users.users.length > 0) {
await page.getByRole("tab", { name: "사용자 목록" }).click();
await expectVisibleButtonsFit(page, ".ad-user__actions .vg-btn", "mobile admin user actions");
}
});
test("keeps the create-user form contained at tablet widths", async ({ page }) => {
test.setTimeout(60_000);
await signInAsAdmin(page);
for (const width of [861, 900, 1024]) {
await page.setViewportSize({ width, height: 900 });
await openAdminAndReadUsers(page);
await page.getByRole("tab", { name: "사용자 등록" }).click();
await expectNoHorizontalOverflow(page);
await expectCreateUserControlsFit(page, width);

View file

@ -112,20 +112,83 @@ test.describe("auth domain policy", () => {
await expect(error).toContainText("오류 코드: provider_error");
});
test("logs in locally with the server dev session and redirects to learner home", async ({
test("logs in locally, completes onboarding, and redirects to learner home", async ({
page,
}) => {
}, testInfo) => {
await page.goto("/login");
await expect(page.locator(".lg-dev")).toBeVisible();
const email = `onboarding.${testInfo.workerIndex}.${Date.now()}@hs.ac.kr`;
const login = await page.request.post("/api/auth/dev-login", {
data: {
email,
role: "learner",
display_name: "온보딩 미완료 학습자",
},
});
expect(login.ok(), await login.text()).toBeTruthy();
await page.goto("/");
await page.waitForURL(/\/onboarding(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
await expect(page.locator(".vg-topbar")).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".ob-card")).toHaveCount(0);
await expect(page.locator(".ob-docs")).toHaveCount(0);
await expect(page.getByText("온보딩 정보를 확인하고 있습니다.")).toHaveCount(0);
await expect(page.locator(".ob-legal")).toContainText("서비스 이용약관");
await expect(page.locator(".ob-legal")).toContainText("개인정보 처리방침");
for (const blockedPath of [
"/learn",
"/settings",
"/admin",
"/dev/avatar-preview",
"/login",
]) {
await page.goto(blockedPath);
await page.waitForURL(/\/onboarding(?:$|[/?#])/);
await expect(page.getByRole("heading", { name: "가입 정보를 입력합니다." })).toBeVisible();
await expect(page.locator(".vg-nav")).toHaveCount(0);
}
await page.getByLabel("닉네임").fill("비넷 학습자");
await page.getByLabel("자기소개").fill("상담 시뮬레이션에서 라포 형성을 집중 연습합니다.");
await page.getByLabel("아바타 이미지").setInputFiles({
name: "avatar.png",
mimeType: "image/png",
buffer: Buffer.from(
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAFgwJ/lTQvYwAAAABJRU5ErkJggg==",
"base64",
),
});
await expect(page.getByText("다른 이미지 선택")).toBeVisible();
await page.getByLabel("이름").fill("로컬 테스트 학습자");
await page.getByLabel("소속").fill("한신대학교");
await page.getByLabel("학과/부서").fill("상담심리학과");
await page.getByLabel("학년/직위").fill("3학년");
await page.getByLabel("연락처").fill("010-1234-5678");
await page.getByLabel("주소/수령지").fill("경기도 오산시 한신대학교");
await page.getByLabel("서비스 이용약관에 동의합니다.").check();
await page.getByLabel("개인정보 수집 및 이용에 동의합니다.").check();
await Promise.all([
page.waitForURL(/\/learn(?:$|[/?#])/),
page.getByRole("button", { name: "로컬 테스트 계정으로 계속" }).click(),
page.getByRole("button", { name: /가입 설정 완료/ }).click(),
]);
const me = await page.request.get("/api/auth/me");
expect(me.status(), await me.text()).toBe(200);
await expect(page.getByRole("heading", { name: "오늘의 회기를 준비합니다." })).toBeVisible();
const meBody = (await me.json()) as {
onboarding_completed_at?: number | null;
nickname?: string;
self_introduction?: string;
avatar_url?: string;
};
expect(meBody.onboarding_completed_at).toBeTruthy();
expect(meBody.nickname).toBe("비넷 학습자");
expect(meBody.self_introduction).toContain("라포 형성");
expect(meBody.avatar_url).toContain("/uploads/profile-avatars/");
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
});
test("keeps dev login closed for the public API origin", async ({ request }) => {

View file

@ -21,6 +21,7 @@ test.describe("avatar expression lab", () => {
display_name: "Avatar Lab Learner",
role: "learner",
cohort_ids: [],
onboarding_completed_at: Math.floor(Date.now() / 1000),
}),
}),
);

View file

@ -1,5 +1,19 @@
import { expect, test } from "@playwright/test";
const P1_PERSONA = {
code: "P1",
display_name: "서연(가명) · 고2 · 우울/자살사고",
difficulty: "hard",
theory_target: ["humanistic", "cbt"],
demographics: { age_band: "16-18", sex: "female", grade: "고2", status: "재학" },
presenting_summary: "우울감과 자살사고 위험",
voice_preset: null,
source: "database",
degraded: false,
expectedExpression: "sad",
expectedModel: "vignette-p1-live2d",
} as const;
const PERSONAS = [
{
code: "P4",
@ -55,6 +69,41 @@ const PERSONAS = [
},
] as const;
async function setLearnerUtterance(page: import("@playwright/test").Page, text: string) {
const textbox = page.getByLabel("학습자 발화 입력");
await expect(textbox).toBeVisible();
await expect(textbox).toBeEnabled();
await textbox.fill(text);
await expect(textbox).toHaveValue(text);
await expect(page.getByRole("button", { name: "보내기" })).toBeEnabled();
}
async function mockSessionDetail(
page: import("@playwright/test").Page,
sessionId: string,
personaCode = "P4",
) {
await page.route(`**/api/sessions/${sessionId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
case_id: "case-avatar-expression",
persona_code: personaCode,
session_no: 1,
status: "active",
stage: "라포",
effective_openness: 0.2,
started_at: new Date().toISOString(),
ended_at: null,
review_ready: false,
turns: [],
}),
});
});
}
test.describe("persona avatar expression rig", () => {
test.beforeEach(async ({ page }) => {
await page.route("**/api/auth/me", (route) =>
@ -67,6 +116,8 @@ test.describe("persona avatar expression rig", () => {
display_name: "Avatar Expression Learner",
role: "learner",
cohort_ids: [],
consent_at: Math.floor(Date.now() / 1000),
onboarding_completed_at: Math.floor(Date.now() / 1000),
}),
}),
);
@ -76,7 +127,7 @@ test.describe("persona avatar expression rig", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify(
PERSONAS.map(
[P1_PERSONA, ...PERSONAS].map(
({
expectedExpression: _expectedExpression,
expectedModel: _expectedModel,
@ -106,11 +157,17 @@ test.describe("persona avatar expression rig", () => {
live2dMotion: el.getAttribute("data-live2d-motion"),
live2dMotionFile: el.getAttribute("data-live2d-motion-file"),
live2dExpressionCount: Number(el.getAttribute("data-live2d-expression-count")),
renderMode: el.getAttribute("data-render-mode"),
rasterArtSet: el.querySelector(".vg-raster")?.getAttribute("data-raster-art-set"),
rasterMode: el.querySelector(".vg-raster")?.getAttribute("data-raster-mode"),
rasterLayerCount: el.querySelectorAll(".vg-raster__layer").length,
rasterBox: el.querySelector<HTMLElement>(".vg-raster")?.getBoundingClientRect().toJSON(),
primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect")
.length,
neckBox: el.querySelector<SVGGraphicsElement>('[data-avatar-neck="true"]')?.getBoundingClientRect().toJSON(),
svgBox: el.querySelector("svg")?.getBoundingClientRect().toJSON(),
}));
const expectedArtSet = `${persona.code.toLowerCase()}-live2d-generated`;
expect(metrics.expressionCount, `${persona.code} expression count`).toBeGreaterThanOrEqual(20);
expect(metrics.live2dSchema, `${persona.code} Live2D schema`).toBe("vignette.live2d.v1");
@ -123,15 +180,21 @@ test.describe("persona avatar expression rig", () => {
`expressions/${persona.expectedExpression}.exp3.json`,
);
expect(metrics.live2dExpressionCount, `${persona.code} Live2D expressions`).toBeGreaterThanOrEqual(20);
expect(metrics.primitiveCount, `${persona.code} avatar SVG primitives`).toBeGreaterThanOrEqual(12);
expect(metrics.neckBox?.width, `${persona.code} visible neck width`).toBeGreaterThan(12);
expect(metrics.neckBox?.height, `${persona.code} visible neck height`).toBeGreaterThan(24);
expect(metrics.svgBox?.width, `${persona.code} avatar SVG width`).toBeGreaterThan(0);
expect(metrics.svgBox?.height, `${persona.code} avatar SVG height`).toBeGreaterThan(0);
expect(metrics.renderMode, `${persona.code} render mode`).toBe("raster");
expect(metrics.rasterArtSet, `${persona.code} generated art set`).toBe(expectedArtSet);
expect(metrics.rasterMode, `${persona.code} raster rig mode`).toBe("psb-detailed");
expect(metrics.rasterLayerCount, `${persona.code} raster layers`).toBeGreaterThanOrEqual(20);
expect(metrics.rasterBox?.width, `${persona.code} raster width`).toBeGreaterThan(0);
expect(metrics.rasterBox?.height, `${persona.code} raster height`).toBeGreaterThan(0);
expect(metrics.primitiveCount, `${persona.code} fallback SVG primitives`).toBe(0);
expect(metrics.neckBox, `${persona.code} SVG neck fallback`).toBeUndefined();
expect(metrics.svgBox, `${persona.code} SVG fallback`).toBeUndefined();
}
});
test("keeps the active session avatar expression wired after session start", async ({ page }) => {
const sessionId = "11111111-1111-4111-8111-111111111111";
await mockSessionDetail(page, sessionId);
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
@ -154,9 +217,12 @@ test.describe("persona avatar expression rig", () => {
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page--active")).toBeVisible();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-render-mode", "raster");
await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-expression-count", "28");
await expect(activeAvatar).toHaveAttribute("data-live2d-model", "vignette-p4-live2d");
@ -168,6 +234,8 @@ test.describe("persona avatar expression rig", () => {
});
test("animates expression transitions after session openness changes", async ({ page }) => {
const sessionId = "22222222-2222-4222-8222-222222222222";
await mockSessionDetail(page, sessionId);
await page.route("**/api/sessions", async (route) => {
if (route.request().method() !== "POST") {
await route.fallback();
@ -177,7 +245,7 @@ test.describe("persona avatar expression rig", () => {
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: "22222222-2222-4222-8222-222222222222",
session_id: sessionId,
case_id: "case-avatar-transition",
session_no: 1,
stage: "라포",
@ -205,16 +273,40 @@ test.describe("persona avatar expression rig", () => {
await page.goto("/learn/session/P4");
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page--active")).toBeVisible();
const activeAvatar = page.locator(".sx-page--active .vg-avatar").first();
await expect(activeAvatar).toHaveAttribute("data-render-mode", "raster");
await expect(activeAvatar.locator('.vg-raster[data-raster-art-set="p4-live2d-generated"]')).toBeVisible();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "anxious");
await page.getByLabel("학습자 발화 입력").fill("조금 안정된 것 같아요.");
await setLearnerUtterance(page, "조금 안정된 것 같아요.");
await page.getByRole("button", { name: "보내기" }).click();
await expect(activeAvatar).toHaveAttribute("data-live2d-motion", "warm");
await expect(activeAvatar).toHaveAttribute("data-live2d-motion-file", "expressions/warm.exp3.json");
await expect(activeAvatar).toHaveAttribute("data-live2d-fade-in-ms", "260");
await expect(activeAvatar).toHaveAttribute("data-live2d-transition-progress", "1.00");
await expect
.poll(
async () =>
Number((await activeAvatar.getAttribute("data-live2d-transition-progress")) ?? "0"),
{ timeout: 15_000 },
)
.toBeGreaterThanOrEqual(0.99);
await expect(page.locator(".sx-stage__now")).toContainText("온화함");
});
test("uses the PSD v2 crying raster rig for P1 Seoyeon", async ({ page }) => {
await page.goto("/learn/session/P1");
const avatar = page.locator('.vg-avatar[data-persona-code="P1"]').first();
await expect(avatar).toBeVisible();
await expect(avatar).toHaveAttribute("data-render-mode", "raster");
await expect(avatar).toHaveAttribute("data-affect", "sad");
const raster = avatar.locator('.vg-raster[data-raster-art-set="seoyeon-live2d-psd-v2"]');
await expect(raster).toBeVisible();
await expect(raster).toHaveAttribute("data-raster-variant", "sad");
await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-left.png"]')).toHaveCount(1);
await expect(raster.locator('.vg-raster__layer--tear[src$="/tear-right.png"]')).toHaveCount(1);
});
});

View file

@ -1,7 +1,9 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { expect, test, type Page } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture";
import {
completeOnboarding,
expectNoHorizontalOverflow,
fetchAvailablePersona,
signInAsLearner,
@ -203,8 +205,13 @@ test.describe("layout visual gate @single-run", () => {
display_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
},
});
await page.request.post("/api/auth/consent", {
data: { accepted: true },
await completeOnboarding(page, {
legal_name: "이름이 아주 길게 표시되는 학습자 케이스 검증용 계정",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "4학년",
phone: "010-3333-3333",
contact_address: "경기도 오산시 한신대학교",
});
// Seed dense history: one active + two ended sessions.
const persona = await fetchAvailablePersona(page);
@ -249,15 +256,11 @@ test.describe("layout visual gate @single-run", () => {
test("session review stays contained across all widths", async ({ page }) => {
await signInAsLearner(page);
const persona = await fetchAvailablePersona(page, 1);
const start = await page.request.post("/api/sessions", {
data: { persona_code: persona.code, theory_mode: "humanistic" },
});
const session = (await start.json()) as { session_id: string };
await page.request.post(`/api/sessions/${session.session_id}/end`);
await page.goto(`/learn/session/${session.session_id}/review`);
await routeFilledSessionReview(page);
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
await gateScreen(page, "session-review", async () => {
await expect(page.locator(".sr-overview")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
});
});
@ -271,6 +274,15 @@ test.describe("layout visual gate @single-run", () => {
});
});
test("persona studio stays contained across all widths", async ({ page }) => {
await signInAsTeacher(page);
await page.goto("/teach/personas");
await gateScreen(page, "persona-studio", async () => {
await expect(page.locator(".ps-layout")).toBeVisible({ timeout: 15_000 });
await expect(page.getByText("내담자 설계·검수 작업면")).toBeVisible();
});
});
test("admin console stays contained across all widths", async ({ page }) => {
await page.request.post("/api/auth/dev-login", {
data: { email: "admin@twentyoz.kr", role: "admin", display_name: "E2E Admin" },

View file

@ -4,6 +4,7 @@ import {
expectNoHorizontalOverflow,
fetchAvailablePersona,
fetchAvailablePersonas,
completeOnboarding,
signInAsLearner,
useRealApi,
} from "./support";
@ -21,10 +22,14 @@ async function signInAsLearnerEmail(
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const consent = await page.request.post("/api/auth/consent", {
data: { accepted: true },
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-2222-2222",
contact_address: "경기도 오산시 한신대학교",
});
expect(consent.ok(), await consent.text()).toBeTruthy();
}
async function createPracticeSession(page: import("@playwright/test").Page, personaCode?: string) {
@ -98,7 +103,7 @@ test.describe("learner app shell and session launcher", () => {
});
test("redirects an unauthenticated learner route to login", async ({ page }) => {
await page.goto("/learn");
await page.goto("/learn/practice");
await expect(page).toHaveURL(/\/login$/);
await expect(page.getByRole("button", { name: /학교 Google 계정으로 계속/ })).toBeVisible();
@ -107,7 +112,7 @@ test.describe("learner app shell and session launcher", () => {
test("renders API personas without legacy session rows", async ({ page }) => {
await signInAsLearnerEmail(page, `learner.catalog.${Date.now()}@hs.ac.kr`, "Catalog Learner");
await page.goto("/learn");
await page.goto("/learn/practice");
const personas = await fetchAvailablePersonas(page);
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
@ -121,8 +126,8 @@ test.describe("learner app shell and session launcher", () => {
await expect(page.getByText(/12회/)).toHaveCount(0);
await expect(page.getByText(/최근 연습/)).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
await expect(page.locator(".vg-main")).toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.locator(".vg-nav")).toBeVisible();
await expect(page.locator(".vg-main")).not.toHaveClass(/(^|\s)vg-main--bleed(\s|$)/);
await expect(page.getByText("음성")).toBeVisible();
await page.getByText("음성").scrollIntoViewIfNeeded();
await expect(page.getByText("음성")).toBeInViewport();
@ -136,7 +141,7 @@ test.describe("learner app shell and session launcher", () => {
test("routes the launcher CTA to the selected database persona", async ({ page }) => {
await signInAsLearner(page);
await page.goto("/learn");
await page.goto("/learn/practice");
const persona = await fetchAvailablePersona(page, 1);
const option = page.getByRole("option", { name: new RegExp(persona.code) });
@ -171,18 +176,45 @@ test.describe("learner app shell and session launcher", () => {
await page.goto("/learn");
await expect(page.locator(".lh-root")).toBeVisible({ timeout: 15_000 });
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
await expect(page.getByLabel("학습 현황")).toContainText("진행 회기");
await expect(page.getByLabel("학습 현황")).toContainText("2회");
await expect(page.getByLabel("학습 현황")).toContainText("리뷰 대기");
await expect(page.getByLabel("학습 현황")).toContainText("최근 평가");
await expect(page.getByLabel("최근 피드백")).toContainText("평가 피드백은 회기 종료 후 표시됩니다.");
await expect(learnerActivityHeading(page)).toBeVisible({ timeout: 15_000 });
await page.goto("/learn/history");
await expect(
page.getByRole("heading", { name: "회기 기록을 찾고 정리합니다." }),
).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".lh-history-main")).toBeVisible();
await expect(page.getByLabel("기록 검색")).toBeVisible();
await expect(page.locator(".lh-history-overview")).toContainText("전체");
await expect(page.locator(".lh-history-overview")).toContainText("진행 중");
await expect(page.locator(".lh-history-overview")).toContainText("보관됨");
await expect(page.getByRole("button", { name: "이어하기" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록" })).toBeVisible();
await expect(page.getByRole("button", { name: "기록", exact: true })).toBeVisible();
await expect(page.getByRole("button", { name: "다시 연습" })).toHaveCount(2);
await expectReachableLearnerHomeLayout(page);
await page.locator(".lh-activity").scrollIntoViewIfNeeded();
await expect(page.locator(".lh-archive-note")).toContainText("읽기 전용");
await expectNoHorizontalOverflow(page);
await page.locator(".lh-history-main").scrollIntoViewIfNeeded();
await expect(page.getByRole("button", { name: "이어하기" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록" })).toBeInViewport();
await expect(page.getByRole("button", { name: "기록", exact: true })).toBeInViewport();
await expect(page.getByRole("button", { name: "다시 연습" }).first()).toBeInViewport();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("2");
await expect(page.locator(".lh-history-main")).toContainText("2개");
await expect(page.locator(".lh-persona-progress")).toContainText(persona.code);
await expect(page.locator(".lh-persona-progress")).toContainText("2회");
await page.getByRole("button", { name: "보관", exact: true }).click();
await expect(page.getByText("회기를 보관했습니다.")).toBeVisible();
await page.locator(".lh-history-filter").getByRole("button", { name: "보관됨" }).click();
await expect(page.locator(".lh-history-main")).toContainText("1개");
await expect(page.getByRole("button", { name: "복원" })).toBeVisible();
await page.getByRole("button", { name: "복원" }).click();
await expect(page.getByText("보관을 해제했습니다.")).toBeVisible();
await expect(page.locator(".lh-history-main")).toContainText("0개");
await page.locator(".lh-history-filter").getByRole("button", { name: "전체" }).click();
await expect(page.locator(".lh-history-main")).toContainText("2개");
await page.getByRole("button", { name: "이어하기" }).click();

View file

@ -1,5 +1,5 @@
import { expect, test, type APIResponse, type Page } from "@playwright/test";
import { expectNoHorizontalOverflow } from "./support";
import { completeOnboarding, expectNoHorizontalOverflow } from "./support";
async function expectResponseOk(response: APIResponse) {
if (!response.ok()) {
@ -17,6 +17,16 @@ async function signIn(
data: { email, role, display_name: displayName },
});
await expectResponseOk(res);
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: role === "admin" ? "운영팀" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: "010-7777-7777",
contact_address: "경기도 오산시 한신대학교",
nickname: displayName,
self_introduction: `${displayName} readiness E2E 사용자입니다.`,
});
}
test.describe("production readiness gates", () => {
@ -46,11 +56,14 @@ test.describe("production readiness gates", () => {
await page.goto("/learn");
await expect(page.getByText("지금까지 12회 연습했어요")).toHaveCount(0);
await expect(page.getByText("최근 8회")).toHaveCount(0);
await expect(page.getByText("기존 회기")).toBeVisible();
await expect(page.locator(".lh-activity__stats")).toContainText("누적 회기");
await expect(page.locator(".lh-activity__stats")).toContainText("0");
await expect(page.getByText("저장된 기존 회기가 없습니다.")).toBeVisible();
await expect(page.getByRole("button", { name: "새 회기 시작" })).toBeInViewport();
await expect(page.getByRole("heading", { name: "오늘 이어갈 회기를 먼저 봅니다." })).toBeVisible();
const statusRegion = page.getByRole("region", { name: "학습 현황" });
await expect(statusRegion.getByRole("button", { name: /진행 회기 0회/ })).toBeVisible();
await expect(statusRegion.getByRole("button", { name: /리뷰 대기 0건/ })).toBeVisible();
await expect(page.getByText("최근 기록이 없습니다.")).toBeVisible();
await expect(
page.locator(".lh-head").getByRole("button", { name: "학습 대상 선택" }),
).toBeInViewport();
await expectNoHorizontalOverflow(page);
});

View file

@ -10,6 +10,7 @@ async function expectSessionPageHeightToMatchViewport(page: Page) {
const metrics = await page.evaluate(() => {
const sessionPage = document.querySelector<HTMLElement>(".sx-page--active");
const topbar = document.querySelector<HTMLElement>(".vg-topbar");
const sessionbar = document.querySelector<HTMLElement>(".sx-sessionbar");
if (!sessionPage) {
return null;
@ -23,11 +24,13 @@ async function expectSessionPageHeightToMatchViewport(page: Page) {
expectedHeight: Math.round(expectedHeight),
delta: Math.abs(pageHeight - expectedHeight),
hasTopbar: Boolean(topbar),
hasSessionbar: Boolean(sessionbar),
};
});
expect(metrics, "Expected active session page to be present").not.toBeNull();
expect(metrics!.hasTopbar, "Active session should hide the global topbar").toBe(false);
expect(metrics!.hasSessionbar, "Active session should show the in-session navigation bar").toBe(true);
expect(
metrics!.delta,
`Expected .sx-page height ${metrics!.pageHeight}px to match viewport ${metrics!.expectedHeight}px`,
@ -76,6 +79,7 @@ async function expectSessionControlsInsideViewport(page: Page) {
".sx-transcript",
".sx-transcript__scroll",
".sx-compose",
".sx-sessionbar",
".sx-controlbar",
];
const result = await page.evaluate((items) => {
@ -421,7 +425,10 @@ test.describe("learner session full-screen layout", () => {
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
await expect(page.locator(".sx-sessionbar")).toBeVisible();
await expect(page.getByRole("button", { name: "기록으로" })).toBeVisible();
await expect(page.locator(".sx-grid")).toBeVisible();
await expect(page.locator(".vg-topbar")).toHaveCount(0);
await expect(page.locator(".vg-nav")).toHaveCount(0);
@ -437,6 +444,12 @@ test.describe("learner session full-screen layout", () => {
await expectNoVisibleSessionPanelOverlap(page);
await expectMainControlsUnclipped(page);
await expectRightPanelDoesNotIntersectSessionCore(page);
await page.reload();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expect(page.locator(".sx-sessionbar")).toBeVisible();
await expect(page.getByRole("button", { name: "회기 시작" })).toHaveCount(0);
await expect(page).toHaveURL(/\/learn\/session\/[0-9a-f-]+$/i);
});
test("keeps critical session controls visible across dense viewport sizes", async ({ page }) => {
@ -461,13 +474,13 @@ test.describe("learner session full-screen layout", () => {
await page.setViewportSize(viewports[0]);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
for (const viewport of viewports) {
await page.setViewportSize(viewport);
await page.evaluate(() => new Promise(requestAnimationFrame));
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await expectNoDocumentOverflow(page);
await expectNoHorizontalOverflow(page);
await expectNoLocalStageDemoControl(page);
@ -498,7 +511,7 @@ test.describe("learner session full-screen layout", () => {
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({
@ -525,7 +538,7 @@ test.describe("learner session full-screen layout", () => {
const persona = await fetchAvailablePersona(page, 1);
await page.goto(`/learn/session/${persona.code}`);
await page.getByRole("button", { name: "회기 시작" }).click();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible();
await expect(page.locator(".sx-page.sx-page--active")).toBeVisible({ timeout: 15_000 });
await page.route("**/api/sessions/*/stream", async (route) => {
await route.fulfill({

View file

@ -1,6 +1,6 @@
import { expect, test, type Page } from "@playwright/test";
const sessionId = "mvp-session-001";
const sessionId = "33333333-3333-4333-8333-333333333333";
const learnerText = "요즘 많이 힘들었겠어요. 어떤 마음이 가장 크게 남아 있나요?";
const clientReply = "괜찮아요. 천천히 말해볼게요.";
@ -15,6 +15,11 @@ async function routeMvpApi(page: Page) {
display_name: "MVP Learner",
role: "learner",
cohort_ids: [],
consent_at: Math.floor(Date.now() / 1000),
onboarding_completed_at: Math.floor(Date.now() / 1000),
nickname: "MVP Learner",
self_introduction: "MVP 회기 흐름 검증용 학습자입니다.",
avatar_url: "",
}),
});
});
@ -59,6 +64,28 @@ async function routeMvpApi(page: Page) {
});
});
await page.route(`**/api/sessions/${sessionId}`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
case_id: "mvp-case-001",
persona_code: "P1",
persona_name: "민서",
session_no: 1,
status: "active",
stage: "라포",
theory_mode: "humanistic",
effective_openness: 0.21,
started_at: new Date().toISOString(),
ended_at: null,
review_ready: false,
turns: [],
}),
});
});
await page.route(`**/api/sessions/${sessionId}/stream`, async (route) => {
await route.fulfill({
status: 200,
@ -176,7 +203,8 @@ test.describe("P1 MVP core loop", () => {
await expect(page.locator(".sx-utt").filter({ hasText: learnerText })).toBeVisible();
await expect(page.locator(".sx-utt").filter({ hasText: clientReply })).toBeVisible();
await page.getByRole("button", { name: /밀어서 회기 종료/ }).press("Enter");
await page.getByRole("button", { name: "회기 종료" }).click();
await page.getByRole("button", { name: "종료하고 리뷰 보기" }).click();
await expect(page).toHaveURL(new RegExp(`/learn/session/${sessionId}/review$`));
await expect(page.getByText("내담자가 남긴 것")).toBeVisible();

View file

@ -0,0 +1,241 @@
import type { Page } from "@playwright/test";
import type { SessionReviewResponse } from "../src/lib/api";
export const FILLED_REVIEW_SESSION_ID = "filled-review-visual";
export function filledReviewResponse(
sessionId: string = FILLED_REVIEW_SESSION_ID,
): SessionReviewResponse {
return {
session_id: sessionId,
client: {
name: "서연",
initial: "서",
persona: "P1 · 고난도 · 인본주의",
},
date: "2026-06-27",
durationLabel: "32분 14초",
durationSeconds: 1934,
reachedPhase: "정리",
sessionSignal: "종료됨",
supervisorState: "평가 완료",
supervisorName: "AI",
summary:
"초반 방어가 강했지만 학습자가 감정 반영과 침묵 허용을 유지하면서 <hl>관계 신호가 안정적으로 회복된 회기</hl>입니다. 다음 회기에서는 비교 경험의 구체 장면을 더 좁혀 다루는 개입이 필요합니다.",
phases: [
{ key: "rapport", label: "라포", weight: 0.22 },
{ key: "explore", label: "탐색", weight: 0.34 },
{ key: "intervene", label: "개입", weight: 0.26 },
{ key: "closing", label: "정리", weight: 0.18 },
],
phaseAxis: ["0:00", "7:30", "18:40", "27:10", "32:14"],
valenceAxis: ["0:00", "8:00", "16:00", "24:00", "32:14"],
clientValence: [
{ t: 0, v: -0.48 },
{ t: 0.18, v: -0.42 },
{ t: 0.38, v: -0.22 },
{ t: 0.62, v: 0.02 },
{ t: 0.82, v: 0.18 },
{ t: 1, v: 0.12 },
],
counselorBaseline: [
{ t: 0, v: -0.1 },
{ t: 0.2, v: -0.05 },
{ t: 0.42, v: 0.03 },
{ t: 0.64, v: 0.08 },
{ t: 0.82, v: 0.1 },
{ t: 1, v: 0.1 },
],
turns: [
{
id: "t1",
ts: "03:12",
speaker: "client",
who: "서연",
text: "엄마가 다른 친구들이랑 비교할 때마다 아무 말도 안 하고 그냥 넘겼어요. 근데 솔직히 계속 제가 이상한 사람 같았어요.",
techniques: [],
nonverbal: [{ kind: "silence", label: "침묵", detail: "4.8초" }],
note: null,
},
{
id: "t2",
ts: "05:04",
speaker: "learner",
who: "나",
text: "그 말을 들을 때마다 서연 씨가 혼자서 버티고 있었다는 느낌이 들어요. 비교가 반복될수록 스스로를 의심하게 됐던 걸까요?",
techniques: [
{ kind: "reflect", label: "감정 반영" },
{ kind: "explore", label: "개방 질문" },
],
nonverbal: [{ kind: "pace", label: "말 속도", detail: "안정" }],
note: {
author: "AI 슈퍼바이저",
tone: "ai",
title: "좋은 연결",
body: "내담자의 경험을 평가하지 않고 정서와 의미를 함께 되짚었습니다.",
quote: "혼자서 버티고 있었다는 느낌",
},
},
{
id: "t3",
ts: "12:41",
speaker: "client",
who: "서연",
text: "네. 그때는 그냥 화가 난다고 말하면 제가 더 나쁜 사람이 될 것 같아서 아무 말도 못 했어요.",
techniques: [],
nonverbal: [{ kind: "audio", label: "음성", detail: "떨림" }],
note: null,
},
{
id: "t4",
ts: "16:05",
speaker: "learner",
who: "나",
text: "화가 났다는 마음 자체가 잘못된 건 아니에요. 그 감정은 서연 씨가 존중받고 싶었다는 신호일 수도 있어요.",
techniques: [
{ kind: "empathy", label: "공감" },
{ kind: "reflect", label: "의미 반영" },
],
nonverbal: [],
note: {
author: "AI 슈퍼바이저",
tone: "ai",
title: "정서 정상화",
body: "방어를 낮추는 데 유효했습니다. 다만 다음 질문에서 장면을 더 구체화하면 평가 근거가 선명해집니다.",
quote: "감정은 존중받고 싶었다는 신호",
},
},
{
id: "t5",
ts: "24:18",
speaker: "learner",
who: "나",
text: "다음에 비슷한 말을 듣는다면 바로 반박하기보다, 먼저 어떤 마음이 올라오는지 알아차리는 연습부터 해볼 수 있을까요?",
techniques: [
{ kind: "explore", label: "협력적 제안" },
{ kind: "confront", label: "부드러운 도전" },
],
nonverbal: [],
note: {
author: "AI 슈퍼바이저",
tone: "warn",
title: "속도 점검",
body: "개입 방향은 적절하지만 내담자의 준비도를 한 번 더 확인하면 좋습니다.",
quote: "연습부터 해볼 수 있을까요",
},
},
{
id: "t6",
ts: "29:52",
speaker: "client",
who: "서연",
text: "바로 괜찮아질지는 모르겠는데, 적어도 제가 화난 게 이상한 건 아니라는 말은 좀 기억에 남아요.",
techniques: [],
nonverbal: [{ kind: "pace", label: "말 속도", detail: "느려짐" }],
note: null,
},
],
rubric: [
{ name: "감정 반영", cluster: "정서 확인", ratio: 0.82, quality: "good", freq: "6회" },
{ name: "개방 질문", cluster: "탐색 촉진", ratio: 0.64, quality: "good", freq: "4회" },
{ name: "정서 정상화", cluster: "방어 완화", ratio: 0.57, quality: "good", freq: "3회" },
{ name: "개입 속도", cluster: "준비도 확인", ratio: 0.35, quality: "watch", freq: "1회" },
],
goodMoments: [
{
title: "방어를 낮춘 감정 반영",
body: "비교 경험을 바로 해석하지 않고 감정과 의미를 먼저 되짚었습니다.",
jumpTo: "t2",
},
{
title: "정서 정상화",
body: "화가 잘못이 아니라 욕구 신호일 수 있다고 재구성했습니다.",
jumpTo: "t4",
},
],
growthPoints: [
{
title: "개입 전 준비도 확인",
body: "행동 제안 전에 내담자가 지금 변화를 다룰 여지가 있는지 확인해야 합니다.",
jumpTo: "t5",
},
{
title: "비교 장면 좁히기",
body: "엄마의 말, 그때 떠오른 생각, 몸 반응을 분리하면 사례개념화 근거가 선명해집니다.",
jumpTo: "t3",
},
],
caseWorksheet: {
status: "draft_from_transcript",
generatedBy: "rule-based transcript extractor",
savedAt: null,
sections: [
{
key: "trigger",
title: "촉발 장면",
items: [
{
key: "comparison",
label: "반복 비교",
value: "엄마가 친구들과 비교하는 말을 반복할 때 자기비난과 분노가 함께 올라옴.",
evidence: [
{
turnId: "t1",
speaker: "client",
quote: "다른 친구들이랑 비교할 때마다",
},
],
confidence: "medium",
emptyReason: null,
},
],
},
{
key: "emotion",
title: "핵심 정서",
items: [
{
key: "anger_shame",
label: "분노와 수치심",
value: "화가 나지만 표현하면 나쁜 사람이 될 것 같아 억제함.",
evidence: [
{
turnId: "t3",
speaker: "client",
quote: "제가 더 나쁜 사람이 될 것 같아서",
},
],
confidence: "medium",
emptyReason: null,
},
],
},
],
limitations: [
"가족관계 정보는 이번 회기 발화에 한정됩니다.",
"위험도 평가는 별도 안전평가 결과와 함께 확인해야 합니다.",
],
},
nextLine:
"그 말을 들은 바로 그 순간, 몸에서는 어떤 반응이 먼저 올라왔나요?",
clientFeedback:
"처음에는 말하기 싫었는데, 화가 이상한 게 아니라는 말은 조금 안심됐어요.",
audioUrl: "/mock/reviews/filled-review-visual.mp3",
pdfExportUrl: "/mock/reviews/filled-review-visual.pdf",
degraded: false,
reviewReady: true,
};
}
export async function routeFilledSessionReview(
page: Page,
sessionId: string = FILLED_REVIEW_SESSION_ID,
) {
await page.route(`**/api/sessions/${sessionId}/review`, async (route) => {
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(filledReviewResponse(sessionId)),
});
});
}

View file

@ -1,4 +1,5 @@
import { expect, test, type Page, type Response } from "@playwright/test";
import { FILLED_REVIEW_SESSION_ID, routeFilledSessionReview } from "./session-review-fixture";
import { expectNoHorizontalOverflow, fetchAvailablePersona, signInAsLearner } from "./support";
interface SessionStartResponse {
@ -56,10 +57,28 @@ async function expectReviewRegionsReachable(page: Page) {
expect(grid.areas).toContain("overview");
expect(grid.areas).toContain("feedback");
expect(grid.areas).toContain("transcript");
expect(grid.areas).not.toContain("teacher");
await expect(page.locator(".sr-overview")).toBeInViewport();
await page.locator(".sr-card--transcript").scrollIntoViewIfNeeded();
await expect(page.locator(".sr-card--transcript")).toBeInViewport();
const railGeometry = await page.locator(".sr-cols").evaluate((gridEl) => {
const overview = gridEl.querySelector(".sr-overview")?.getBoundingClientRect();
const transcript = gridEl.querySelector(".sr-card--transcript")?.getBoundingClientRect();
const columns = window
.getComputedStyle(gridEl)
.gridTemplateColumns.split(" ")
.filter(Boolean).length;
return {
columns,
overviewRight: overview ? Math.ceil(overview.right) : 0,
transcriptLeft: transcript ? Math.floor(transcript.left) : 0,
};
});
if (railGeometry.columns >= 3) {
expect(railGeometry.overviewRight).toBeLessThanOrEqual(railGeometry.transcriptLeft);
}
}
test.describe("session review", () => {
@ -101,4 +120,38 @@ test.describe("session review", () => {
);
await expectNoHorizontalOverflow(page);
});
test("renders filled review evidence state without clipping", async ({ page }) => {
await signInAsLearner(page);
await routeFilledSessionReview(page);
const reviewResponsePromise = page.waitForResponse(isReviewResponse(FILLED_REVIEW_SESSION_ID));
await page.goto(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review`);
const reviewResponse = await reviewResponsePromise;
await expectResponseOk(reviewResponse);
const review = await reviewResponse.json();
expect(review.session_id).toBe(FILLED_REVIEW_SESSION_ID);
expect(review.reviewReady).toBe(true);
expect(review.turns).toHaveLength(6);
await expect(page).toHaveURL(new RegExp(`/learn/session/${FILLED_REVIEW_SESSION_ID}/review$`));
await expect(page.getByText("관계 신호가 안정적으로 회복된 회기")).toBeVisible();
await expect(page.getByText("감정 밸런스 타임라인")).toBeVisible();
await expect(page.getByText("회기 흐름")).toBeVisible();
await expect(page.getByText("세션 트랜스크립트")).toBeVisible();
await expect(page.getByText("기법 사용 분포")).toBeVisible();
await expect(page.getByText("사례개념화 워크시트")).toBeVisible();
await expect(page.getByText("감정 반영").first()).toBeVisible();
await expect(page.getByText("AI 슈퍼바이저").first()).toBeVisible();
await expect(page.getByText("다음에 시도할 문장")).toBeVisible();
await expect(page.getByText("방어를 낮춘 감정 반영")).toBeVisible();
await expect(page.getByText("개입 전 준비도 확인")).toBeVisible();
await expect(page.locator(".sr-chart__svg")).toBeVisible();
await expect(page.locator(".sr-ws-input").first()).toBeVisible();
await expect(page.getByRole("button", { name: "오디오 다시 듣기" })).toBeEnabled();
await expect(page.getByRole("button", { name: "PDF 내보내기" })).toBeEnabled();
await expectReviewRegionsReachable(page);
await expectNoHorizontalOverflow(page);
});
});

View file

@ -1,5 +1,10 @@
import { expect, test, type APIResponse, type Page, type Response, type TestInfo } from "@playwright/test";
import { expectNoHorizontalOverflow, useRealApi, withGlobalEngineConfigLock } from "./support";
import {
completeOnboarding,
expectNoHorizontalOverflow,
useRealApi,
withGlobalEngineConfigLock,
} from "./support";
type Role = "learner" | "admin";
@ -379,6 +384,16 @@ async function signInAs(
},
});
await expectResponseOk(res);
await completeOnboarding(page, {
legal_name: displayName,
affiliation: "한신대학교",
department: role === "admin" ? "운영팀" : "상담심리학과",
grade_level: role === "admin" ? "관리자" : "3학년",
phone: role === "admin" ? "010-2222-2222" : "010-3333-3333",
contact_address: "경기도 오산시 한신대학교",
nickname: displayName,
self_introduction: `${displayName} 설정 E2E 사용자입니다.`,
});
return email;
}

View file

@ -69,10 +69,14 @@ export async function signInAsLearner(page: Page) {
},
});
expect(res.ok(), await res.text()).toBeTruthy();
const consent = await page.request.post("/api/auth/consent", {
data: { accepted: true },
await completeOnboarding(page, {
legal_name: "E2E Learner",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-0000-0000",
contact_address: "경기도 오산시 한신대학교",
});
expect(consent.ok(), await consent.text()).toBeTruthy();
}
export async function signInAsTeacher(page: Page) {
@ -84,6 +88,42 @@ export async function signInAsTeacher(page: Page) {
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await completeOnboarding(page, {
legal_name: "E2E Teacher",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "교수",
phone: "010-1111-1111",
contact_address: "경기도 오산시 한신대학교",
});
}
export async function completeOnboarding(
page: Page,
profile: {
legal_name: string;
affiliation: string;
department: string;
grade_level: string;
phone: string;
contact_address: string;
nickname?: string;
self_introduction?: string;
avatar_url?: string;
},
) {
const onboarding = await page.request.post("/api/users/me/onboarding", {
data: {
...profile,
nickname: profile.nickname ?? profile.legal_name,
self_introduction:
profile.self_introduction ?? "상담 시뮬레이션 훈련을 위한 테스트 사용자입니다.",
avatar_url: profile.avatar_url ?? "",
terms_accepted: true,
privacy_accepted: true,
},
});
expect(onboarding.ok(), await onboarding.text()).toBeTruthy();
}
export async function fetchAvailablePersonas(page: Page): Promise<E2EPersona[]> {

View file

@ -94,6 +94,96 @@ function recentSessionFixture(index: number) {
};
}
function teacherReviewResponse(sessionId: string) {
return {
session_id: sessionId,
client: {
name: "서연",
initial: "서",
persona: "P1 · 고난도",
},
date: "2026-06-27",
durationLabel: "32분",
durationSeconds: 1920,
reachedPhase: "정리",
sessionSignal: "종료됨",
supervisorState: "평가 완료",
supervisorName: "AI",
teacherReview: {
status: "pending",
note: "",
reviewerId: null,
reviewedAt: null,
updatedAt: null,
},
summary: "교수자가 검토할 수 있는 종료 회기 리뷰입니다.",
phases: [{ key: "closing", label: "정리", weight: 1 }],
phaseAxis: ["0:00", "32:00"],
valenceAxis: [],
clientValence: [],
counselorBaseline: [],
turns: [
{
id: "t1",
ts: "01:00",
speaker: "learner",
who: "학습자",
text: "오늘은 비교당할 때의 감정을 더 살펴보고 싶습니다.",
techniques: [{ kind: "explore", label: "탐색 질문" }],
nonverbal: [],
note: {
author: "평가 AI",
tone: "good",
title: "검토 가능",
body: "교수자가 읽을 수 있는 자동 평가 노트입니다.",
quote: null,
},
},
{
id: "t2",
ts: "02:10",
speaker: "client",
who: "서연",
text: "말하기는 어렵지만 계속 비교당하는 게 힘들어요.",
techniques: [],
nonverbal: [],
note: null,
},
],
rubric: [],
goodMoments: [],
growthPoints: [],
caseWorksheet: {
status: "draft_from_transcript",
generatedBy: "rule-based transcript extractor",
sections: [
{
key: "trigger",
title: "촉발 장면",
items: [
{
key: "comparison",
label: "비교 경험",
value: "반복 비교 상황에서 감정 탐색이 필요함.",
evidence: [{ turnId: "t2", speaker: "client", quote: "계속 비교당하는 게 힘들어요" }],
confidence: "medium",
emptyReason: null,
},
],
},
],
limitations: ["교수자 검토 화면에서는 학습자 제출물을 수정하지 않습니다."],
savedAt: null,
},
nextLine: null,
clientFeedback: null,
audioUrl: null,
pdfExportUrl: null,
degraded: false,
reviewReady: true,
};
}
test.describe("teacher console", () => {
test("lets a teacher approve a pending persona review from the console @single-run", async ({
page,
@ -168,6 +258,143 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("lets a teacher revise an approved persona from persona studio @single-run", async ({ page }) => {
const personaId = "00000000-0000-0000-0000-000000000701";
let revisionRequested = false;
const approvedPersona = {
persona_id: personaId,
code: "P1",
version: 1,
status: "approved",
display_name: "서연(가명) · 고2 · 우울/자살사고",
difficulty: "hard",
theory_target: ["humanistic"],
demographics: { age_band: "F-teen" },
presenting_summary: "최근 무기력과 자살사고를 호소합니다.",
source: "database",
degraded: false,
voice_preset: null,
};
const draftDetail = {
...approvedPersona,
version: 2,
status: "draft",
source_provenance: "bootstrap seed migrated to editable catalog",
is_synthetic: true,
created_at: "2026-06-28T00:00:00Z",
approved_at: null,
presenting: { complaint: "최근 무기력과 자살사고를 호소합니다." },
history: { family: "가족 갈등" },
big5: { O: 0.5, C: 0.4, E: 0.3, A: 0.6, N: 0.8 },
resistance: { base_resistance: 0.6, unlock_rate: 0.1, decay_floor: 0.05 },
speech_style: { register: "polite", honorific: true },
affect_baseline: { negative_affect: 0.7, anxiety: 0.4, suicide_ideation_stage: 1 },
ccd: { core_belief: "나는 짐이 된다" },
dsm5_dimensional: { depression: "moderate" },
triggers: { sore_spots: ["평가절하"], forbidden: ["단정"] },
};
await signInAsTeacher(page);
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify([approvedPersona]),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }),
);
await page.route(`**/api/personas/${personaId}/revisions`, async (route) => {
revisionRequested = true;
expect(route.request().method()).toBe("POST");
expect(route.request().postDataJSON()).toEqual({ submit_for_review: false });
await route.fulfill({
status: 201,
contentType: "application/json",
body: JSON.stringify(draftDetail),
});
});
await page.goto("/teach/personas");
const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P1" });
await expect(row).toBeVisible();
await row.getByRole("button", { name: "수정" }).click();
await expect(page.getByText("P1 v2 수정 초안을 불러왔습니다.")).toBeVisible();
await expect(page.getByLabel("표시 이름")).toHaveValue("서연(가명) · 고2 · 우울/자살사고");
await expect(page.getByLabel("코드")).toHaveValue("P1");
expect(revisionRequested).toBeTruthy();
await expectNoHorizontalOverflow(page);
});
test("archives an approved persona from persona studio without layout drift @single-run", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
const personaId = "00000000-0000-0000-0000-000000000702";
let archived = false;
const approvedPersona = {
persona_id: personaId,
code: "P7",
version: 1,
status: "approved",
display_name: "도현(가명) · 고3 · 입시 번아웃/무기력",
difficulty: "moderate",
theory_target: ["humanistic"],
demographics: { age_band: "M-teen" },
presenting_summary: "입시 번아웃과 무기력",
source: "database",
degraded: false,
voice_preset: null,
};
await signInAsTeacher(page);
await page.route("**/api/personas", (route) => {
if (route.request().method() !== "GET") return route.fallback();
return route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(archived ? [] : [approvedPersona]),
});
});
await page.route("**/api/personas/review", (route) =>
route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify([]) }),
);
await page.route(`**/api/personas/${personaId}`, async (route) => {
archived = true;
expect(route.request().method()).toBe("DELETE");
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
persona_id: personaId,
code: "P7",
version: 1,
status: "archived",
display_name: approvedPersona.display_name,
difficulty: approvedPersona.difficulty,
theory_target: approvedPersona.theory_target,
source_provenance: "bootstrap seed migrated to editable catalog",
is_synthetic: true,
created_at: "2026-06-28T00:00:00Z",
approved_at: null,
}),
});
});
await page.goto("/teach/personas");
const row = page.locator('[data-approved-persona-row="true"]').filter({ hasText: "P7" });
await expect(row).toBeVisible();
await expectVisibleButtonsFit(page, ".ps-approved-row__actions .vg-btn", "approved persona actions");
page.once("dialog", (dialog) => dialog.accept());
await row.getByRole("button", { name: "삭제" }).click();
await expect(page.getByText("P7 페르소나를 보관 처리했습니다.")).toBeVisible();
await expect(page.getByText("공개 목록 없음")).toBeVisible();
expect(archived).toBeTruthy();
await expectNoHorizontalOverflow(page);
});
test("renders real server sessions from server-owned rows", async ({ page }) => {
const sessionId = await createEndedLearnerSession(page);
await signInAsTeacher(page);
@ -189,6 +416,262 @@ test.describe("teacher console", () => {
await expectNoHorizontalOverflow(page);
});
test("opens a pending session review from the teacher queue @single-run", async ({ page }) => {
const sessionId = "teacher-review-fixture-session";
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 1,
active_sessions: 0,
ended_sessions: 1,
learner_growth: [],
safety_alerts: [],
pending_reviews: [
{
session_id: sessionId,
learner_id: "00000000-0000-0000-0000-000000000111",
learner_label: "E2E Learner",
persona_code: "P1",
persona_name: "서연",
session_no: 1,
status: "ended",
stage: "정리",
turn_count: 6,
learner_turn_count: 3,
client_turn_count: 3,
started_at: "2026-06-27T07:00:00Z",
ended_at: "2026-06-27T07:32:00Z",
review_status: "pending",
review_note: null,
reviewed_at: null,
},
],
recent_sessions: [],
message: "교수자 검토 대기 회기가 있습니다.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(teacherReviewResponse(sessionId)),
}),
);
await page.route(`**/api/teacher/sessions/${sessionId}/review-status`, async (route) => {
const body = route.request().postDataJSON() as { status: string; note: string };
await route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
session_id: sessionId,
status: body.status,
note: body.note,
reviewer_id: "00000000-0000-0000-0000-000000000901",
reviewed_at: body.status === "closed" ? "2026-06-27T10:00:00Z" : null,
updated_at: "2026-06-27T10:00:00Z",
}),
});
});
await signInAsTeacher(page);
await page.goto("/teach");
await expect(page.locator(".pf-triage")).toBeVisible();
await expect(page.locator(".pf-triage")).toContainText("리뷰 대기");
const row = page.getByRole("button", { name: /E2E Learner P1 회기 상세 검토/ });
await expect(row).toBeVisible();
await row.click();
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("교수자 검토", { exact: true })).toBeVisible();
await expect(page.getByText("축어록 자동 초안 읽기 전용")).toBeVisible();
await expect(page.getByText("검토 전용")).toBeVisible();
await page.getByLabel("검토 메모").fill("다음 회기에서 감정 반영을 먼저 확인");
await page.getByRole("button", { name: "검토 완료" }).click();
await expect(page.getByText("완료 시각 2026-06-27T10:00:00Z")).toBeVisible();
await expect(page.getByRole("button", { name: "검토 완료" })).toBeDisabled();
await expect(page.getByRole("button", { name: "교수 콘솔로" })).toBeVisible();
await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("opens recent ended and active sessions from the teacher history @single-run", async ({
page,
}) => {
const endedSession = {
...recentSessionFixture(1),
session_id: "teacher-recent-ended-session",
learner_label: "Recent Ended Learner",
persona_code: "P1",
status: "ended",
ended_at: "2026-06-27T07:32:00Z",
};
const activeSession = {
...recentSessionFixture(2),
session_id: "teacher-recent-active-session",
learner_label: "Recent Active Learner",
persona_code: "P2",
status: "active",
ended_at: null,
};
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 2,
active_sessions: 1,
ended_sessions: 1,
learner_growth: [],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [endedSession, activeSession],
message: "최근 회기 기록 fixture.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
await page.route(`**/api/sessions/${endedSession.session_id}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(teacherReviewResponse(endedSession.session_id)),
}),
);
await page.route(`**/api/sessions/${activeSession.session_id}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
...teacherReviewResponse(activeSession.session_id),
sessionSignal: "진행 중",
supervisorState: "평가 대기",
summary: "진행 중인 세션의 현재 기록을 교수자가 읽기 전용으로 확인합니다.",
reviewReady: false,
degraded: true,
}),
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
const endedRow = page.getByRole("button", {
name: /Recent Ended Learner P1 상세 리뷰/,
});
await expect(endedRow).toBeVisible();
await endedRow.click();
await expect(page).toHaveURL(new RegExp(`/teach/session/${endedSession.session_id}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("종료됨", { exact: true })).toBeVisible();
await page.getByRole("button", { name: "교수 콘솔로" }).click();
const activeRow = page.getByRole("button", {
name: /Recent Active Learner P2 진행 기록/,
});
await expect(activeRow).toBeVisible();
await activeRow.click();
await expect(page).toHaveURL(new RegExp(`/teach/session/${activeSession.session_id}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("진행 중", { exact: true })).toBeVisible();
await expect(page.getByText("평가 대기", { exact: true })).toBeVisible();
await expectNoHorizontalOverflow(page);
});
test("opens a recent session review from the teacher history table @single-run", async ({ page }) => {
const sessionId = "teacher-recent-fixture-session";
await page.route("**/api/teacher/dashboard", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify({
source: "database",
cohort_label: "E2E cohort",
total_learners: 1,
active_sessions: 1,
ended_sessions: 0,
learner_growth: [],
safety_alerts: [],
pending_reviews: [],
recent_sessions: [
{
session_id: sessionId,
learner_id: "00000000-0000-0000-0000-000000000222",
learner_label: "Recent Learner",
persona_code: "P2",
persona_name: "민재",
session_no: 2,
status: "active",
stage: "탐색",
turn_count: 4,
learner_turn_count: 2,
client_turn_count: 2,
started_at: "2026-06-27T08:00:00Z",
ended_at: null,
},
],
message: "최근 진행 회기가 있습니다.",
}),
}),
);
await page.route("**/api/personas/review", (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: "[]",
}),
);
const review = teacherReviewResponse(sessionId);
review.sessionSignal = "진행 중";
review.supervisorState = "평가 대기";
review.reviewReady = false;
await page.route(`**/api/sessions/${sessionId}/review`, (route) =>
route.fulfill({
status: 200,
contentType: "application/json",
body: JSON.stringify(review),
}),
);
await signInAsTeacher(page);
await page.goto("/teach");
const row = page.getByRole("button", { name: /Recent Learner P2 진행 기록/ });
await expect(row).toBeVisible();
await row.press("Enter");
await expect(page).toHaveURL(new RegExp(`/teach/session/${sessionId}/review$`));
await expect(page.getByText("교수자 검토 화면", { exact: true })).toBeVisible();
await expect(page.getByText("진행 중", { exact: true })).toBeVisible();
await expect(page.getByText("검토 전용")).toBeVisible();
await expect(page.getByRole("button", { name: /^저장/ })).toHaveCount(0);
await expectNoHorizontalOverflow(page);
});
test("keeps recent sessions readable without horizontal scrolling across breakpoints", async ({
page,
}) => {
@ -254,8 +737,8 @@ test.describe("teacher console", () => {
if (metrics.viewportWidth <= 860) {
expect(metrics.headerDisplay).toBe("none");
expect(metrics.rowDisplay).toBe("grid");
expect(metrics.gridCellCount).toBe(6);
expect(metrics.labels).toEqual(["페르소나", "상태", "단계", "턴", "시작", "종료"]);
expect(metrics.gridCellCount).toBe(7);
expect(metrics.labels).toEqual(["페르소나", "상태", "단계", "턴", "시작", "종료", "열기"]);
} else {
expect(metrics.headerDisplay).toBe("grid");
expect(metrics.headerPosition).toBe("sticky");

View file

@ -41,6 +41,9 @@ interface VoiceUiProbeState {
recorderStops: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
audioBufferStarts: number;
mediaPlayRejections: number;
messages: VoiceUiProbeMessage[];
closeEvents: number[];
}
@ -206,6 +209,7 @@ async function startApi({
ENGINE_CONNECT_TIMEOUT: "2",
OPENAI_API_KEY: "e2e-fake-key",
OPENAI_BASE_URL: `${openAIBaseURL}/v1`,
VIGNETTE_VOICE_POC_SAMPLE_TTS: "false",
FRONTEND_BASE_URL: frontendBaseURL,
CORS_ORIGINS: JSON.stringify([frontendBaseURL]),
},
@ -356,8 +360,11 @@ async function probeVoiceCascade(page: Page, apiBaseURL: string, sessionId: stri
);
}
async function installSyntheticVoiceCapture(page: Page): Promise<void> {
await page.addInitScript(() => {
async function installSyntheticVoiceCapture(
page: Page,
options: { blockMediaElementPlayback?: boolean } = {},
): Promise<void> {
await page.addInitScript((opts) => {
type ProbeMessage = {
direction: "sent" | "received";
kind: "text" | "binary";
@ -370,6 +377,9 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
recorderStops: number;
trackStops: number;
audioPlays: number;
audioContextResumes: number;
audioBufferStarts: number;
mediaPlayRejections: number;
messages: ProbeMessage[];
closeEvents: number[];
};
@ -380,6 +390,9 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
recorderStops: 0,
trackStops: 0,
audioPlays: 0,
audioContextResumes: 0,
audioBufferStarts: 0,
mediaPlayRejections: 0,
messages: [],
closeEvents: [],
};
@ -507,14 +520,93 @@ async function installSyntheticVoiceCapture(page: Page): Promise<void> {
value: ProbeWebSocket,
});
class FakeAnalyser {
fftSize = 1024;
connect() {
return this;
}
disconnect() {
return undefined;
}
getFloatTimeDomainData(buf: Float32Array) {
for (let i = 0; i < buf.length; i += 1) buf[i] = 0;
}
}
class FakeBufferSource {
buffer: unknown = null;
onended: ((event: Event) => void) | null = null;
connect() {
return this;
}
disconnect() {
return undefined;
}
start() {
probe.audioBufferStarts += 1;
window.setTimeout(() => {
this.onended?.(new Event("ended"));
}, 120);
}
stop() {
this.onended = null;
}
}
class FakeAudioContext {
state = "running";
destination = {};
async resume() {
probe.audioContextResumes += 1;
this.state = "running";
}
async close() {
this.state = "closed";
}
async decodeAudioData(_data: ArrayBuffer) {
return { duration: 0.12 };
}
createAnalyser() {
return new FakeAnalyser();
}
createBufferSource() {
return new FakeBufferSource();
}
}
Object.defineProperty(window, "AudioContext", {
configurable: true,
value: FakeAudioContext,
});
Object.defineProperty(window, "webkitAudioContext", {
configurable: true,
value: FakeAudioContext,
});
HTMLMediaElement.prototype.play = function patchedPlay() {
if (opts.blockMediaElementPlayback) {
probe.mediaPlayRejections += 1;
return Promise.reject(new DOMException("Synthetic autoplay block", "NotAllowedError"));
}
probe.audioPlays += 1;
window.setTimeout(() => {
this.dispatchEvent(new Event("ended"));
}, 120);
return Promise.resolve();
};
});
}, options);
}
async function readVoiceUiProbe(page: Page): Promise<VoiceUiProbeState> {
@ -562,6 +654,33 @@ test.describe("voice cascade success path", () => {
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const onboarding = await fetch(`${apiBase}/users/me/onboarding`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
legal_name: "Voice Success",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-5555-5555",
contact_address: "경기도 오산시 한신대학교",
nickname: "Voice Success",
self_introduction: "음성 캐스케이드 성공 경로 검증용 사용자입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
}),
});
const onboardingBody = await onboarding.text();
if (!onboarding.ok) {
return {
ok: false,
step: "onboarding",
status: onboarding.status,
body: onboardingBody,
};
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
@ -613,7 +732,6 @@ test.describe("voice cascade success path", () => {
expect.objectContaining({ type: "transcript", text: "요즘 잠을 잘 못 자요." }),
expect.objectContaining({ type: "reply", text: "괜찮아요. 천천히 말해볼게요." }),
expect.objectContaining({ type: "state", state: "speaking" }),
expect.objectContaining({ type: "tts_chunk", seq: 0 }),
expect.objectContaining({ type: "tts_end" }),
expect.objectContaining({ type: "state", state: "idle" }),
]),
@ -646,7 +764,7 @@ test.describe("voice cascade success path", () => {
}
});
await installSyntheticVoiceCapture(page);
await installSyntheticVoiceCapture(page, { blockMediaElementPlayback: true });
const openai = await startFakeOpenAI();
const engine = await startFakeEngine();
@ -703,6 +821,33 @@ test.describe("voice cascade success path", () => {
if (!login.ok) {
return { ok: false, step: "login", status: login.status, body: loginBody };
}
const onboarding = await fetch(`${apiBase}/users/me/onboarding`, {
method: "POST",
credentials: "include",
headers: { "content-type": "application/json" },
body: JSON.stringify({
legal_name: "Voice UI",
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-6666-6666",
contact_address: "경기도 오산시 한신대학교",
nickname: "Voice UI",
self_introduction: "브라우저 음성 UI 검증용 사용자입니다.",
avatar_url: "",
terms_accepted: true,
privacy_accepted: true,
}),
});
const onboardingBody = await onboarding.text();
if (!onboarding.ok) {
return {
ok: false,
step: "onboarding",
status: onboarding.status,
body: onboardingBody,
};
}
const me = await fetch(`${apiBase}/auth/me`, { credentials: "include" });
const meBody = await me.text();
if (!me.ok) {
@ -794,7 +939,10 @@ test.describe("voice cascade success path", () => {
await expect(page.locator(".sx-utt").filter({ hasText: String(reply) })).toBeVisible();
const probe = await readVoiceUiProbe(page);
expect(probe.audioPlays).toBeGreaterThan(0);
expect(probe.audioContextResumes).toBeGreaterThan(0);
expect(probe.audioBufferStarts).toBeGreaterThan(0);
expect(probe.audioPlays).toBe(0);
expect(probe.mediaPlayRejections).toBe(0);
expect(probe.messages.some((message) => message.direction === "received" && message.kind === "binary")).toBe(
true,
);

View file

@ -1,5 +1,5 @@
import { expect, test, type Page, type TestInfo } from "@playwright/test";
import { fetchAvailablePersona } from "./support";
import { completeOnboarding, fetchAvailablePersona } from "./support";
interface WsResult {
code: number;
@ -15,6 +15,16 @@ async function signInLearner(page: Page, testInfo: TestInfo, label: string) {
},
});
expect(res.ok(), await res.text()).toBeTruthy();
await completeOnboarding(page, {
legal_name: `Voice ${label}`,
affiliation: "한신대학교",
department: "상담심리학과",
grade_level: "3학년",
phone: "010-4444-4444",
contact_address: "경기도 오산시 한신대학교",
nickname: `Voice ${label}`,
self_introduction: "음성 WebSocket 경계 검증용 E2E 사용자입니다.",
});
}
async function openVoiceSocket(page: Page, path: string): Promise<WsResult> {

View file

@ -6,9 +6,56 @@
<meta name="color-scheme" content="light dark" />
<meta
name="description"
content="Vignette — 교육용 AI 심리상담 시뮬레이션 훈련 플랫폼. 실제 치료·진단을 대체하지 않습니다."
content="Vignette는 상담 수련생이 AI 내담자와 회기를 연습하고, 평가 피드백과 사례개념화 워크시트를 확인하는 교육용 AI 심리상담 시뮬레이션 훈련 플랫폼입니다."
/>
<meta name="robots" content="index,follow,max-snippet:160,max-image-preview:large" />
<link rel="canonical" href="https://vignette.chanpaca.net/" />
<meta property="og:type" content="website" />
<meta property="og:locale" content="ko_KR" />
<meta property="og:site_name" content="Vignette" />
<meta property="og:title" content="Vignette · AI 심리상담 시뮬레이션 훈련" />
<meta
property="og:description"
content="AI 내담자 회기 연습, 평가 피드백, 사례개념화 워크시트를 한 흐름으로 제공하는 상담 교육 플랫폼."
/>
<meta property="og:url" content="https://vignette.chanpaca.net/" />
<meta
property="og:image"
content="https://vignette.chanpaca.net/design-elements/clinical-paper-ambient.png"
/>
<meta property="og:image:width" content="1672" />
<meta property="og:image:height" content="941" />
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="Vignette · AI 심리상담 시뮬레이션 훈련" />
<meta
name="twitter:description"
content="상담 수련생을 위한 AI 내담자 회기 연습과 평가 피드백 플랫폼."
/>
<meta
name="twitter:image"
content="https://vignette.chanpaca.net/design-elements/clinical-paper-ambient.png"
/>
<title>Vignette · 상담 시뮬레이션</title>
<script type="application/ld+json">
{
"@context": "https://schema.org",
"@type": "WebApplication",
"name": "Vignette",
"url": "https://vignette.chanpaca.net/",
"applicationCategory": "EducationalApplication",
"operatingSystem": "Web",
"inLanguage": "ko-KR",
"description": "상담 수련생이 AI 내담자와 회기를 연습하고 평가 피드백과 사례개념화 워크시트를 확인하는 교육용 AI 심리상담 시뮬레이션 훈련 플랫폼.",
"audience": {
"@type": "EducationalAudience",
"educationalRole": "counseling trainee"
},
"provider": {
"@type": "Organization",
"name": "Vignette"
}
}
</script>
<!-- 본문/UI 기본 폰트: Pretendard Variable (DESIGN_CONCEPT §3.6) -->
<link
rel="stylesheet"

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 143 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 169 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 410 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 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: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7 KiB

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