diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..ad28a88 --- /dev/null +++ b/.dockerignore @@ -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 diff --git a/.gitignore b/.gitignore index cd320d8..57449e9 100644 --- a/.gitignore +++ b/.gitignore @@ -33,6 +33,7 @@ venv/ *.egg-info/ .pytest_cache/ .ruff_cache/ +apps/api/uploads/ # === Docker / 런타임 === *.pid diff --git a/AGENTS.md b/AGENTS.md index 52136cc..7dc08c7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ...`. diff --git a/README.md b/README.md index 6b10ed7..e3f4114 100644 --- a/README.md +++ b/README.md @@ -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를 확인한다. --- diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile index 4e068f7..29f2402 100644 --- a/apps/api/Dockerfile +++ b/apps/api/Dockerfile @@ -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 로 주입(이미지에 굽지 않음 = 이식성) diff --git a/apps/api/Dockerfile.rag b/apps/api/Dockerfile.rag deleted file mode 100644 index 9fdf21c..0000000 --- a/apps/api/Dockerfile.rag +++ /dev/null @@ -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"] diff --git a/apps/api/app/auth_types.py b/apps/api/app/auth_types.py new file mode 100644 index 0000000..4a03c39 --- /dev/null +++ b/apps/api/app/auth_types.py @@ -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"] diff --git a/apps/api/app/config.py b/apps/api/app/config.py index a413d15..afae5ab 100644 --- a/apps/api/app/config.py +++ b/apps/api/app/config.py @@ -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( diff --git a/apps/api/app/contracts/__init__.py b/apps/api/app/contracts/__init__.py new file mode 100644 index 0000000..2c933cd --- /dev/null +++ b/apps/api/app/contracts/__init__.py @@ -0,0 +1 @@ +"""Cross-runtime contracts for replaceable service boundaries.""" diff --git a/apps/api/app/contracts/engine_gateway.py b/apps/api/app/contracts/engine_gateway.py new file mode 100644 index 0000000..21294d7 --- /dev/null +++ b/apps/api/app/contracts/engine_gateway.py @@ -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" diff --git a/apps/api/app/db.py b/apps/api/app/db.py index 53f9e83..b69dc1c 100644 --- a/apps/api/app/db.py +++ b/apps/api/app/db.py @@ -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 diff --git a/apps/api/app/deps.py b/apps/api/app/deps.py index 45040be..2b9a605 100644 --- a/apps/api/app/deps.py +++ b/apps/api/app/deps.py @@ -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 diff --git a/apps/api/app/engine_client.py b/apps/api/app/engine_client.py index f90e35c..42c204b 100644 --- a/apps/api/app/engine_client.py +++ b/apps/api/app/engine_client.py @@ -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): diff --git a/apps/api/app/main.py b/apps/api/app/main.py index 18385df..79aae85 100644 --- a/apps/api/app/main.py +++ b/apps/api/app/main.py @@ -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 가능 보장. diff --git a/apps/api/app/paths.py b/apps/api/app/paths.py new file mode 100644 index 0000000..4c59ede --- /dev/null +++ b/apps/api/app/paths.py @@ -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) diff --git a/apps/api/app/persona_repository.py b/apps/api/app/persona_repository.py index 1686357..c6ffa18 100644 --- a/apps/api/app/persona_repository.py +++ b/apps/api/app/persona_repository.py @@ -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", diff --git a/apps/api/app/routes/admin.py b/apps/api/app/routes/admin.py index 10566e5..918be6b 100644 --- a/apps/api/app/routes/admin.py +++ b/apps/api/app/routes/admin.py @@ -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) diff --git a/apps/api/app/routes/kb.py b/apps/api/app/routes/kb.py index 5043f58..ff9adfe 100644 --- a/apps/api/app/routes/kb.py +++ b/apps/api/app/routes/kb.py @@ -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, + ) diff --git a/apps/api/app/routes/personas.py b/apps/api/app/routes/personas.py index b970173..903baf7 100644 --- a/apps/api/app/routes/personas.py +++ b/apps/api/app/routes/personas.py @@ -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, diff --git a/apps/api/app/routes/sessions.py b/apps/api/app/routes/sessions.py index b32ebd9..6d1b498 100644 --- a/apps/api/app/routes/sessions.py +++ b/apps/api/app/routes/sessions.py @@ -10,22 +10,24 @@ from __future__ import annotations import asyncio import json +import re +import secrets from collections import Counter from datetime import datetime from typing import Literal, Optional -from fastapi import APIRouter, HTTPException, status +from fastapi import APIRouter, HTTPException, Request, status from pydantic import BaseModel, Field from sse_starlette.sse import EventSourceResponse from .. import db, session_persistence, turn_runtime -from ..auth_sessions import user_has_consent +from ..auth_sessions import user_has_consent, user_onboarding_complete from ..config import settings from ..deps import CurrentPrincipal, Principal, Role from ..engine_client import EngineError, engine_client from ..persona_repository import get_catalog_persona from ..runtime_policy import require_runtime_fallback_allowed -from ..services import evaluator, memory, orchestrator, rag, state_machine +from ..services import evaluator, guardrail, live_coach, memory, orchestrator, rag, session_metrics, state_machine from ..store import InProcSession, TurnRecord, store router = APIRouter(prefix="/sessions", tags=["sessions"]) @@ -54,6 +56,17 @@ class TurnRequest(BaseModel): text: str = Field(..., min_length=1) +class LiveCoachRequest(BaseModel): + learner_text: str = Field(..., min_length=1) + client_reply: Optional[str] = None + turn_seq: Optional[int] = Field(default=None, ge=1) + + +class LiveCoachHistoryResponse(BaseModel): + source: Literal["database", "runtime"] = "runtime" + events: list[live_coach.LiveCoachEvent] = Field(default_factory=list) + + class CrisisResourceResponse(BaseModel): title: str number: str @@ -91,6 +104,8 @@ class LearnerSessionSummary(BaseModel): started_at: str ended_at: str | None = None review_ready: bool = False + archived: bool = False + archived_at: str | None = None class LearnerSessionsResponse(BaseModel): @@ -98,6 +113,94 @@ class LearnerSessionsResponse(BaseModel): sessions: list[LearnerSessionSummary] = Field(default_factory=list) +class LearnerDashboardOverview(BaseModel): + total_sessions: int = 0 + completed_sessions: int = 0 + active_sessions: int = 0 + review_ready_sessions: int = 0 + archived_sessions: int = 0 + learner_turns: int = 0 + client_turns: int = 0 + last_practiced_at: str | None = None + + +class LearnerDashboardGrowthPoint(BaseModel): + session_id: str + session_no: int + persona_code: str + stage: StageLabel + started_at: str + ended_at: str | None = None + score: float | None = None + rapport: float | None = None + technique_count: int = 0 + watch_count: int = 0 + + +class LearnerDashboardGrowth(BaseModel): + 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" + evaluated_sessions: int = 0 + top_techniques: list[str] = Field(default_factory=list) + points: list[LearnerDashboardGrowthPoint] = Field(default_factory=list) + + +class LearnerDashboardPersonaProgress(BaseModel): + persona_code: str + persona_name: str + sessions: int = 0 + completed_sessions: int = 0 + active_sessions: int = 0 + review_ready_sessions: int = 0 + latest_at: str | None = None + latest_stage: StageLabel | None = None + latest_score: float | None = None + trend: str = "insufficient" + + +class LearnerDashboardAchievement(BaseModel): + id: str + label: str + state: Literal["done", "available", "locked"] = "locked" + detail: str + + +class LearnerDashboardFeedbackItem(BaseModel): + session_id: str + persona_code: str + persona_name: str + session_no: int + stage: StageLabel + turn_seq: int + created_at: str + score: float | None = None + rapport: float | None = None + note: str + techniques: list[str] = Field(default_factory=list) + + +class LearnerDashboardResponse(BaseModel): + source: str = "runtime" + overview: LearnerDashboardOverview + growth: LearnerDashboardGrowth + persona_progress: list[LearnerDashboardPersonaProgress] = Field(default_factory=list) + achievements: list[LearnerDashboardAchievement] = Field(default_factory=list) + recent_feedback: list[LearnerDashboardFeedbackItem] = Field(default_factory=list) + message: str + + +class SessionArchiveResponse(BaseModel): + session_id: str + archived: bool + archived_at: str | None = None + source: str = "runtime" + session: LearnerSessionSummary + + class SessionDetailTurn(BaseModel): turn_seq: int speaker: Literal["learner", "client"] @@ -216,6 +319,14 @@ class ReviewCaseWorksheetSaveRequest(BaseModel): limitations: list[str] = Field(default_factory=list) +class SessionTeacherReviewStatus(BaseModel): + status: Literal["pending", "viewed", "closed"] = "pending" + note: str = "" + reviewerId: str | None = None + reviewedAt: str | None = None + updatedAt: str | None = None + + class SessionReviewResponse(BaseModel): session_id: str client: ReviewClient @@ -243,6 +354,19 @@ class SessionReviewResponse(BaseModel): pdfExportUrl: Optional[str] = None degraded: bool = True reviewReady: bool = False + teacherReview: SessionTeacherReviewStatus | None = None + + +class SessionShareResponse(BaseModel): + shareUrl: str + title: str + description: str + imageUrl: str + createdAt: str + + +class SessionShareDeleteResponse(BaseModel): + revoked: bool _RECALL_CACHE: dict[str, memory.RecallContext] = {} @@ -297,6 +421,71 @@ async def _retrieve_kb_behavior_cues(card) -> list[str]: return [] +async def _retrieve_live_coach_grounding( + *, + learner_text: str, + client_reply: str | None, + stage: str, + theory_mode: str, +) -> list[live_coach.LiveCoachGrounding]: + """라이브 코치용 평가 근거 회수. 미가용 시 빈 리스트로 진행한다.""" + learner_masked = guardrail.mask_pii(learner_text).text_masked + client_masked = guardrail.mask_pii(client_reply or "").text_masked + query = " ".join( + part for part in [stage, theory_mode, learner_masked, client_masked] if part + ).strip() + if not query: + return [] + try: + async with db.acquire(ai_view=rag.AIRole.EVALUATOR.value) as conn: + result = await rag.retrieve_eval_grounding( + conn, + query=query, + k=4, + kinds=("theory", "technique", "supervisor_pattern", "microskill", "taxonomy"), + ) + try: + await rag.log_retrieval( + conn, + result=result, + ai_role="evaluator", + used_in_answer=True, + ) + except Exception: + pass + except Exception: + return [] + + out: list[live_coach.LiveCoachGrounding] = [] + for chunk in result.chunks: + body = chunk.body or chunk.behavior_cue or chunk.context_prefix or "" + if not body: + continue + out.append( + live_coach.LiveCoachGrounding( + source_id=chunk.source_id or f"kb:{chunk.chunk_id}", + title=chunk.source_id or "Vignette KB", + locator=chunk.heading_path, + kb_kind=chunk.kb_kind, + summary=body[:500], + ) + ) + return out + + +def _latest_turn_evaluation(sess: InProcSession, turn_seq: int | None) -> Optional[dict]: + """방금 상담자 발화에 붙은 fast-loop 평가를 찾는다.""" + for turn in reversed(sess.turns): + if turn.speaker != "counselor": + continue + if turn_seq is not None and turn.turn_seq != turn_seq: + continue + if isinstance(turn.evaluation, dict): + return turn.evaluation + return None + return None + + async def _ensure_kb_cues(session_id: str, card) -> list[str]: """세션별 KB 행동단서(회기 1회 산출·캐시, 서버 재시작/재개 시 lazy 재계산).""" cached = _KB_CUES_CACHE.get(session_id) @@ -441,9 +630,12 @@ def _stage_label(stage: object) -> str: return turn_runtime.stage_label(stage) -def _ensure_learner(principal: Principal) -> None: - if principal.role != Role.LEARNER: - raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions") +def _ensure_learner(principal: Principal) -> Principal: + if principal.role == Role.LEARNER: + return principal + if principal.super_admin: + return principal.with_role(Role.LEARNER) + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="only learners can use sessions") async def _ensure_practice_consent(principal: Principal) -> None: @@ -454,6 +646,14 @@ async def _ensure_practice_consent(principal: Principal) -> None: raise HTTPException(status.HTTP_403_FORBIDDEN, detail="consent_required") +async def _ensure_onboarding_complete(principal: Principal) -> None: + if principal.profile_completed_at is not None: + return + if await user_onboarding_complete(principal.user_id): + return + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="onboarding_required") + + async def _load_session_or_404( session_id: str, principal: Principal, @@ -477,6 +677,35 @@ async def _load_session_or_404( return sess +async def _load_review_session_or_404( + session_id: str, + principal: Principal, + *, + include_turn_evaluation: bool = False, +) -> InProcSession: + if principal.role == Role.LEARNER: + return await _load_session_or_404( + session_id, + principal, + allow_ended=True, + include_turn_evaluation=include_turn_evaluation, + ) + if principal.role not in {Role.TEACHER, Role.ADMIN}: + raise HTTPException(status.HTTP_403_FORBIDDEN, detail="session review access denied") + + sess = await session_persistence.load_session( + session_id, + principal, + allow_ended=True, + include_turn_evaluation=include_turn_evaluation, + ) + if sess is None and turn_runtime.runtime_fallback_allowed(): + sess = store.get(session_id) + if sess is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, detail="session not found") + return sess + + async def _end_persisted_session(sess: InProcSession, carry: memory.CarryOver) -> None: if await session_persistence.end_session(sess, carry): sess.ended = True @@ -867,6 +1096,62 @@ def _saved_case_worksheet_from_payload(payload: dict[str, object] | None) -> Rev return worksheet.model_copy(update={"status": "saved_by_learner"}) +def _worksheet_share_highlights(worksheet: ReviewCaseWorksheet, *, limit: int = 4) -> list[dict[str, str]]: + highlights: list[dict[str, str]] = [] + for section in worksheet.sections: + for item in section.items: + value = _compact_text(item.value or "") + if not value: + continue + highlights.append( + { + "section": section.title, + "label": item.label, + "value": "비공개 요약 항목", + } + ) + if len(highlights) >= limit: + return highlights + return highlights + + +def _review_point_titles(points: list[ReviewPoint], *, limit: int = 3) -> list[str]: + return [_clip_text(point.title or point.body, 72) for point in points[:limit] if (point.title or point.body)] + + +def _share_image_url() -> str: + return f"{settings.frontend_base_url.rstrip('/')}/design-elements/clinical-paper-ambient.png" + + +def _session_share_payload(review: SessionReviewResponse) -> dict[str, object]: + title = f"Vignette 회기 리뷰 · {review.client.name} {review.date}" + description = _clip_text(review.summary, 156) + return { + "version": 1, + "title": title, + "description": description, + "summary": _clip_text(review.summary, 420), + "clientName": review.client.name, + "persona": review.client.persona, + "date": review.date, + "durationLabel": review.durationLabel, + "reachedPhase": review.reachedPhase, + "sessionSignal": review.sessionSignal, + "reviewReady": review.reviewReady, + "goodMoments": _review_point_titles(review.goodMoments), + "growthPoints": _review_point_titles(review.growthPoints), + "worksheetHighlights": _worksheet_share_highlights(review.caseWorksheet), + "imageUrl": _share_image_url(), + "appUrl": settings.frontend_base_url.rstrip("/"), + "privacy": "공유 카드에는 회기 원문 축어록과 학습자 식별 정보를 포함하지 않습니다.", + } + + +def _public_share_url(request: Request, token: str) -> str: + base = str(request.base_url).rstrip("/") + return f"{base}/share/session/{token}" + + def _evaluation_payload(record: dict[str, object] | None) -> dict[str, object]: if not record: return {} @@ -900,28 +1185,89 @@ def _review_techniques_from_turn_eval(ev: dict[str, object] | None) -> list[Revi return out -def _review_note_from_turn_eval(ev: dict[str, object] | None) -> Optional[ReviewNote]: +def _review_note_body_markdown(text: str) -> str: + """평가 노트 본문을 회기 리뷰용 제한 markdown으로 정돈한다.""" + body = text.strip() + body = re.sub( + r"`?\beffective[_\s-]?openness\b`?(?!\(유효 개방도\))", + "`effective openness(유효 개방도)`", + body, + flags=re.IGNORECASE, + ) + body = re.sub( + r"(? str | None: + clean = " ".join(str(text or "").split()) + if not clean: + return None + sentences = [part.strip() for part in re.split(r"(?<=[.!?。!?])\s+", clean) if part.strip()] + for sentence in sentences: + if "가장 큰 마음" in sentence: + return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..." + for sentence in sentences: + if "?" in sentence: + return sentence if len(sentence) <= limit else f"{sentence[: limit - 3].rstrip()}..." + return clean if len(clean) <= limit else f"{clean[: limit - 3].rstrip()}..." + + +def _review_note_from_turn_eval( + ev: dict[str, object] | None, + learner_text: str | None = None, +) -> Optional[ReviewNote]: """의도이탈(있으면 우선) 또는 적절성 신호를 턴 노트로. tone: good|warn(프론트 계약).""" if not isinstance(ev, dict): return None + quote = _review_quote_excerpt(learner_text) dev = ev.get("intent_deviation") if isinstance(dev, dict): dimension = str(dev.get("dimension") or "").strip() expected = str(dev.get("expected") or "").strip() actual = str(dev.get("actual") or "").strip() - body = " / ".join(p for p in (f"권장: {expected}" if expected else "", f"실제: {actual}" if actual else "") if p) + body_parts = ( + f"- 권장: {expected}" if expected else "", + f"- 실제: {actual}" if actual else "", + ) + body = "\n".join(p for p in body_parts if p) return ReviewNote( author="평가 AI", tone="warn", title=f"의도와 다른 부분 · {dimension}".rstrip(" ·") or "의도와 다른 부분", - body=body or "권장 반응과 실제 반응에 차이가 있었어요.", + body=_review_note_body_markdown(body or "권장 반응과 실제 반응에 차이가 있었어요."), + quote=quote, ) appropriateness = str(ev.get("appropriateness") or "neutral") note_text = str(ev.get("appropriateness_note") or "").strip() if appropriateness == "pos": - return ReviewNote(author="평가 AI", tone="good", title="적절한 개입", body=note_text or "이 개입은 흐름에 적절했어요.") + return ReviewNote( + author="평가 AI", + tone="good", + title="적절한 개입", + body=_review_note_body_markdown( + note_text + or ( + "타당화·공감·탐색이 회기 흐름에 맞았습니다.\n\n" + "`effective openness(유효 개방도)`가 낮은 내담자라면 다음 질문은 " + "더 작고 구체적인 선택지로 낮춰도 좋습니다." + ) + ), + quote=quote, + ) if appropriateness == "warn" and note_text: - return ReviewNote(author="평가 AI", tone="warn", title="점검해볼 지점", body=note_text) + return ReviewNote( + author="평가 AI", + tone="warn", + title="점검해볼 지점", + body=_review_note_body_markdown(note_text), + quote=quote, + ) return None @@ -1067,7 +1413,13 @@ def _schedule_session_evaluation(sess: InProcSession) -> None: asyncio.create_task(_generate_and_save_session_evaluation(sess)) -def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> LearnerSessionSummary: +def _learner_summary( + sess: InProcSession, + *, + review_ready: bool = False, + archived: bool = False, + archived_at: str | None = None, +) -> LearnerSessionSummary: turns = _learner_visible_turns(sess) learner_turns = sum(1 for turn in turns if turn.speaker == "counselor") client_turns = sum(1 for turn in turns if turn.speaker == "client") @@ -1084,9 +1436,34 @@ def _learner_summary(sess: InProcSession, *, review_ready: bool = False) -> Lear started_at=_iso(sess.created_at) or "", ended_at=_iso(sess.ended_at), review_ready=review_ready, + archived=archived, + archived_at=archived_at, ) +async def _load_learner_sessions( + principal: Principal, + *, + include_turn_evaluation: bool = False, +) -> tuple[list[InProcSession], bool]: + if include_turn_evaluation: + sessions, durable = await session_persistence.list_sessions( + principal, + include_turn_evaluation=True, + ) + else: + sessions, durable = await session_persistence.list_sessions(principal) + if not durable: + require_runtime_fallback_allowed("session list") + sessions = [ + sess + for sess in store.list() + if sess.learner_id == principal.user_id + ] + sessions.sort(key=lambda sess: sess.created_at, reverse=True) + return sessions, durable + + async def _review_ready(sess: InProcSession, principal: Principal) -> bool: turns = _learner_visible_turns(sess) if not sess.ended or not turns: @@ -1100,6 +1477,175 @@ async def _review_ready(sess: InProcSession, principal: Principal) -> bool: return bool(evaluation_record and evaluation_record.get("status") == "ready") +async def _review_ready_map( + sessions: list[InProcSession], + principal: Principal, +) -> dict[str, bool]: + results = await asyncio.gather(*[_review_ready(sess, principal) for sess in sessions]) + return {sess.session_id: ready for sess, ready in zip(sessions, results)} + + +async def _archive_map( + sessions: list[InProcSession], + principal: Principal, +) -> dict[str, dict[str, object]]: + records, _ = await session_persistence.list_session_archives( + [sess.session_id for sess in sessions], + principal, + ) + return records + + +def _dashboard_growth_point(point: session_metrics.SessionGrowthPoint) -> LearnerDashboardGrowthPoint: + return LearnerDashboardGrowthPoint( + session_id=point.session_id, + session_no=point.session_no, + persona_code=point.persona_code, + stage=_stage_label(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 _dashboard_growth(sessions: list[InProcSession]) -> LearnerDashboardGrowth: + metrics = session_metrics.build_learner_growth( + sessions, + learner_label=lambda _learner_id: "나", + limit=1, + ) + if not metrics: + return LearnerDashboardGrowth() + item = metrics[0] + points = [_dashboard_growth_point(point) for point in item.points] + return LearnerDashboardGrowth( + 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, + evaluated_sessions=sum(1 for point in points if point.score is not None), + top_techniques=item.top_techniques, + points=points, + ) + + +def _dashboard_persona_progress( + sessions: list[InProcSession], + review_ready: dict[str, bool], +) -> list[LearnerDashboardPersonaProgress]: + grouped: dict[str, list[InProcSession]] = {} + for sess in sessions: + grouped.setdefault(sess.persona_code, []).append(sess) + + rows: list[LearnerDashboardPersonaProgress] = [] + for persona_code, items in grouped.items(): + ordered = sorted(items, key=session_metrics.session_activity_time) + latest = ordered[-1] + metrics = session_metrics.build_learner_growth( + ordered, + learner_label=lambda _learner_id: "나", + limit=1, + ) + growth = metrics[0] if metrics else None + rows.append( + LearnerDashboardPersonaProgress( + persona_code=persona_code, + persona_name=latest.persona.display_name, + sessions=len(ordered), + completed_sessions=sum(1 for sess in ordered if sess.ended), + active_sessions=sum(1 for sess in ordered if not sess.ended), + review_ready_sessions=sum( + 1 for sess in ordered if review_ready.get(sess.session_id, False) + ), + latest_at=session_metrics.iso_datetime( + session_metrics.session_activity_time(latest) + ), + latest_stage=_stage_label(latest.state.stage), + latest_score=growth.latest_score if growth else None, + trend=growth.trend if growth else "insufficient", + ) + ) + return sorted( + rows, + key=lambda row: row.latest_at or "", + reverse=True, + ) + + +def _achievement_state(done: bool, available: bool) -> Literal["done", "available", "locked"]: + if done: + return "done" + if available: + return "available" + return "locked" + + +def _dashboard_achievements( + sessions: list[InProcSession], + review_ready: dict[str, bool], +) -> list[LearnerDashboardAchievement]: + completed = sum(1 for sess in sessions if sess.ended) + active = sum(1 for sess in sessions if not sess.ended) + review_count = sum(1 for ready in review_ready.values() if ready) + by_persona = Counter(sess.persona_code for sess in sessions) + max_persona_sessions = max(by_persona.values(), default=0) + persona_coverage = len(by_persona) + return [ + LearnerDashboardAchievement( + id="first_session_complete", + label="첫 회기 완료", + state=_achievement_state(completed >= 1, active >= 1), + detail="한 회기를 종료하면 리뷰와 워크시트 흐름이 열립니다.", + ), + LearnerDashboardAchievement( + id="review_ready", + label="리뷰 확인 가능", + state=_achievement_state(review_count >= 1, completed >= 1), + detail=f"현재 리뷰 가능한 회기 {review_count}건입니다.", + ), + LearnerDashboardAchievement( + id="persona_repeat", + label="같은 내담자 반복 연습", + state=_achievement_state(max_persona_sessions >= 3, max_persona_sessions >= 1), + detail="같은 페르소나를 반복하면 변화 추이를 더 안정적으로 볼 수 있습니다.", + ), + LearnerDashboardAchievement( + id="persona_coverage", + label="여러 페르소나 경험", + state=_achievement_state(persona_coverage >= 3, persona_coverage >= 2), + detail=f"현재 {persona_coverage}개 페르소나에서 연습 기록이 있습니다.", + ), + ] + + +def _dashboard_feedback(sessions: list[InProcSession]) -> list[LearnerDashboardFeedbackItem]: + items: list[LearnerDashboardFeedbackItem] = [] + for item in session_metrics.recent_feedback_notes(sessions, limit=5): + score = item.get("score") + rapport = item.get("rapport") + items.append( + LearnerDashboardFeedbackItem( + session_id=str(item["session_id"]), + persona_code=str(item["persona_code"]), + persona_name=str(item["persona_name"]), + session_no=int(item["session_no"]), + stage=_stage_label(item["stage"]), + turn_seq=int(item["turn_seq"]), + created_at=str(item["created_at"]), + score=float(score) if isinstance(score, (int, float)) else None, + rapport=float(rapport) if isinstance(rapport, (int, float)) else None, + note=str(item["note"]), + techniques=[str(label) for label in item.get("techniques", [])], + ) + ) + return items + + def _session_detail( sess: InProcSession, *, @@ -1131,23 +1677,47 @@ def _session_detail( ) +async def _session_archive_response( + sess: InProcSession, + principal: Principal, + *, + archived: bool, + archived_at: str | None, + source: str, +) -> SessionArchiveResponse: + return SessionArchiveResponse( + session_id=sess.session_id, + archived=archived, + archived_at=archived_at, + source=source, + session=_learner_summary( + sess, + review_ready=await _review_ready(sess, principal), + archived=archived, + archived_at=archived_at, + ), + ) + + @router.get("", response_model=LearnerSessionsResponse) async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsResponse: """Return the current learner's real practice sessions.""" - _ensure_learner(principal) - sessions, durable = await session_persistence.list_sessions(principal) - if not durable: - require_runtime_fallback_allowed("session list") - sessions = [ - sess - for sess in store.list() - if sess.learner_id == principal.user_id - ] - sessions.sort(key=lambda sess: sess.created_at, reverse=True) + principal = _ensure_learner(principal) + sessions, durable = await _load_learner_sessions(principal) + archives = await _archive_map(sessions, principal) summaries: list[LearnerSessionSummary] = [] for sess in sessions[:20]: - summaries.append(_learner_summary(sess, review_ready=await _review_ready(sess, principal))) + archive_record = archives.get(sess.session_id) + archived_at = archive_record.get("archived_at") if archive_record else None + summaries.append( + _learner_summary( + sess, + review_ready=await _review_ready(sess, principal), + archived=archive_record is not None, + archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None, + ) + ) return LearnerSessionsResponse( source="database" if durable else "runtime", @@ -1155,13 +1725,57 @@ async def list_learner_sessions(principal: CurrentPrincipal) -> LearnerSessionsR ) +@router.get("/dashboard", response_model=LearnerDashboardResponse) +async def learner_dashboard(principal: CurrentPrincipal) -> LearnerDashboardResponse: + """Return the current learner's real practice dashboard aggregates.""" + principal = _ensure_learner(principal) + sessions, durable = await _load_learner_sessions( + principal, + include_turn_evaluation=True, + ) + review_ready = await _review_ready_map(sessions, principal) + archives = await _archive_map(sessions, principal) + visible_review_ready = { + session_id: ready + for session_id, ready in review_ready.items() + if session_id not in archives + } + overview = LearnerDashboardOverview( + total_sessions=len(sessions), + completed_sessions=sum(1 for sess in sessions if sess.ended), + active_sessions=sum(1 for sess in sessions if not sess.ended), + review_ready_sessions=sum(1 for ready in visible_review_ready.values() if ready), + archived_sessions=len(archives), + learner_turns=sum(1 for sess in sessions for turn in _learner_visible_turns(sess) if turn.speaker == "counselor"), + client_turns=sum(1 for sess in sessions for turn in _learner_visible_turns(sess) if turn.speaker == "client"), + last_practiced_at=session_metrics.iso_datetime( + max((session_metrics.session_activity_time(sess) for sess in sessions), default=0.0) + ) + if sessions + else None, + ) + return LearnerDashboardResponse( + source="database" if durable else "runtime", + overview=overview, + growth=_dashboard_growth(sessions), + persona_progress=_dashboard_persona_progress(sessions, visible_review_ready), + achievements=_dashboard_achievements(sessions, visible_review_ready), + recent_feedback=_dashboard_feedback(sessions), + message=( + "실제 연습 기록을 기준으로 개인 학습 흐름을 표시합니다." + if sessions + else "아직 표시할 실제 연습 기록이 없습니다." + ), + ) + + @router.get("/{session_id}", response_model=SessionDetailResponse) async def get_session_detail( session_id: str, principal: CurrentPrincipal, ) -> SessionDetailResponse: """Return a learner-owned session with transcript for resume/history.""" - _ensure_learner(principal) + principal = _ensure_learner(principal) sess = await _load_session_or_404( session_id, principal, @@ -1170,13 +1784,69 @@ async def get_session_detail( return _session_detail(sess, review_ready=await _review_ready(sess, principal)) +@router.post("/{session_id}/archive", response_model=SessionArchiveResponse) +async def archive_session( + session_id: str, + principal: CurrentPrincipal, +) -> SessionArchiveResponse: + """Archive an ended learner-owned session without deleting transcript or review evidence.""" + principal = _ensure_learner(principal) + sess = await _load_session_or_404( + session_id, + principal, + allow_ended=True, + ) + if not sess.ended: + raise HTTPException(status.HTTP_409_CONFLICT, detail="active sessions cannot be archived") + record, durable = await session_persistence.set_session_archived( + session_id=sess.session_id, + learner_id=principal.user_id, + archived=True, + ) + archived_at = record.get("archived_at") if record else None + return await _session_archive_response( + sess, + principal, + archived=True, + archived_at=_iso(float(archived_at)) if isinstance(archived_at, (int, float)) else None, + source="database" if durable else "runtime", + ) + + +@router.post("/{session_id}/restore", response_model=SessionArchiveResponse) +async def restore_archived_session( + session_id: str, + principal: CurrentPrincipal, +) -> SessionArchiveResponse: + """Restore an archived learner-owned session to the normal history/review queues.""" + principal = _ensure_learner(principal) + sess = await _load_session_or_404( + session_id, + principal, + allow_ended=True, + ) + _, durable = await session_persistence.set_session_archived( + session_id=sess.session_id, + learner_id=principal.user_id, + archived=False, + ) + return await _session_archive_response( + sess, + principal, + archived=False, + archived_at=None, + source="database" if durable else "runtime", + ) + + @router.post("", response_model=SessionStartResponse, status_code=status.HTTP_201_CREATED) async def start_session( body: SessionStartRequest, principal: CurrentPrincipal, ) -> SessionStartResponse: """Start a learner-owned practice session.""" - _ensure_learner(principal) + principal = _ensure_learner(principal) + await _ensure_onboarding_complete(principal) await _ensure_practice_consent(principal) try: @@ -1248,12 +1918,10 @@ async def get_session_review( session_id: str, principal: CurrentPrincipal, ) -> SessionReviewResponse: - """Return a learner-safe review built only from the stored session transcript.""" - _ensure_learner(principal) - sess = await _load_session_or_404( + """Return a role-safe review built only from the stored session transcript.""" + sess = await _load_review_session_or_404( session_id, principal, - allow_ended=True, include_turn_evaluation=True, ) visible_turns = _learner_visible_turns(sess) @@ -1297,7 +1965,7 @@ async def get_session_review( text=turn.text_masked, techniques=_review_techniques_from_turn_eval(turn_eval), nonverbal=_review_nonverbal_events(turn) if speaker == "learner" else [], - note=_review_note_from_turn_eval(turn_eval), + note=_review_note_from_turn_eval(turn_eval, turn.text_masked), ) ) @@ -1357,6 +2025,19 @@ async def get_session_review( principal, ) case_worksheet = _saved_case_worksheet_from_payload(saved_worksheet_payload) or generated_worksheet + teacher_review: SessionTeacherReviewStatus | None = None + if principal.role in {Role.TEACHER, Role.ADMIN}: + review_status, _ = await session_persistence.load_session_review_status(session_id, principal) + review_status_value = str((review_status or {}).get("status") or "pending") + if review_status_value not in {"viewed", "closed"}: + review_status_value = "pending" + teacher_review = SessionTeacherReviewStatus( + status=review_status_value, # type: ignore[arg-type] + note=str((review_status or {}).get("note") or ""), + reviewerId=str((review_status or {}).get("reviewer_id") or "") or None, + reviewedAt=str((review_status or {}).get("reviewed_at") or "") or None, + updatedAt=str((review_status or {}).get("updated_at") or "") or None, + ) return SessionReviewResponse( session_id=session_id, @@ -1389,9 +2070,67 @@ async def get_session_review( pdfExportUrl=None, degraded=review_degraded, reviewReady=evaluation_ready, + teacherReview=teacher_review, ) +@router.post("/{session_id}/share", response_model=SessionShareResponse) +async def create_session_share( + session_id: str, + request: Request, + principal: CurrentPrincipal, +) -> SessionShareResponse: + """Create a public unfurl URL for a learner-owned ended session review.""" + principal = _ensure_learner(principal) + sess = await _load_session_or_404( + session_id, + principal, + allow_ended=True, + include_turn_evaluation=True, + ) + if not sess.ended: + raise HTTPException(status.HTTP_409_CONFLICT, detail="session must be ended before sharing") + + review = await get_session_review(session_id, principal) + token = secrets.token_urlsafe(32) + payload = _session_share_payload(review) + saved = await session_persistence.save_session_share( + session_id=session_id, + learner_id=principal.user_id, + token_hash=session_persistence.share_token_hash(token), + payload=payload, + ) + if saved is None: + raise HTTPException( + status.HTTP_503_SERVICE_UNAVAILABLE, + detail="session share persistence unavailable", + ) + created_at = saved.get("created_at") + created_label = _iso(created_at if isinstance(created_at, (int, float)) else datetime.now().timestamp()) or "" + return SessionShareResponse( + shareUrl=_public_share_url(request, token), + title=str(payload["title"]), + description=str(payload["description"]), + imageUrl=str(payload["imageUrl"]), + createdAt=created_label, + ) + + +@router.delete("/{session_id}/share", response_model=SessionShareDeleteResponse) +async def revoke_session_share( + session_id: str, + principal: CurrentPrincipal, +) -> SessionShareDeleteResponse: + """Revoke the public share URL for a learner-owned session.""" + principal = _ensure_learner(principal) + await _load_session_or_404(session_id, principal, allow_ended=True) + revoked = await session_persistence.revoke_session_share( + session_id=session_id, + learner_id=principal.user_id, + ) + return SessionShareDeleteResponse(revoked=revoked) + + @router.put("/{session_id}/review/worksheet", response_model=ReviewCaseWorksheet) async def save_session_review_worksheet( session_id: str, @@ -1399,7 +2138,7 @@ async def save_session_review_worksheet( principal: CurrentPrincipal, ) -> ReviewCaseWorksheet: """Persist the learner's edited case formulation worksheet for this session.""" - _ensure_learner(principal) + principal = _ensure_learner(principal) await _load_session_or_404( session_id, principal, @@ -1432,7 +2171,7 @@ async def submit_turn( principal: CurrentPrincipal, ) -> TurnResponse: """Submit one trainee utterance and return the generated client reply.""" - _ensure_learner(principal) + principal = _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) recall = await ensure_recall_context(sess) kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful) @@ -1486,6 +2225,72 @@ async def submit_turn( ) +@router.get("/{session_id}/live-coach", response_model=LiveCoachHistoryResponse) +async def list_live_coach_history( + session_id: str, + principal: CurrentPrincipal, +) -> LiveCoachHistoryResponse: + """현재 회기에서 학습자에게 실제로 전달된 라이브 코칭 이력을 반환한다.""" + principal = _ensure_learner(principal) + await _load_session_or_404(session_id, principal) + events, durable = await session_persistence.list_live_coach_events(session_id, principal) + return LiveCoachHistoryResponse( + source="database" if durable else "runtime", + events=[live_coach.LiveCoachEvent(**event) for event in events], + ) + + +@router.post("/{session_id}/live-coach", response_model=live_coach.LiveCoachSuggestion) +async def live_coach_turn( + session_id: str, + body: LiveCoachRequest, + principal: CurrentPrincipal, +) -> live_coach.LiveCoachSuggestion: + """방금 완료된 턴에 대한 비차단 라이브 코칭을 반환한다.""" + principal = _ensure_learner(principal) + sess = await _load_session_or_404(session_id, principal) + turn_seq = body.turn_seq or max(1, int(getattr(sess.state, "turn_seq", 1) or 1)) + stage = _stage_label(sess.state.stage) + grounding = await _retrieve_live_coach_grounding( + learner_text=body.learner_text, + client_reply=body.client_reply, + stage=stage, + theory_mode=sess.theory_mode, + ) + item = live_coach.LiveCoachInput( + session_id=sess.session_id, + turn_seq=turn_seq, + stage=stage, + effective_openness=sess.state.effective_openness, + theory_mode=sess.theory_mode, + persona_code=sess.persona_code, + persona_name=sess.persona.display_name, + learner_text=body.learner_text, + client_reply=body.client_reply, + recent_turns=sess.recent_turns(k=8, visible_to=_LEARNER_VISIBLE_AI_ROLE), + evaluation=_latest_turn_evaluation(sess, body.turn_seq), + ) + suggestion = await live_coach.generate_live_coaching( + item, + engine=engine_client, + grounding=grounding, + audit_hook=session_persistence.record_llm_call_audit, + ) + try: + await session_persistence.save_live_coach_event( + session_id=sess.session_id, + learner_id=sess.learner_id, + turn_seq=turn_seq, + stage=stage, + learner_text=body.learner_text, + client_reply=body.client_reply, + suggestion=suggestion, + ) + except Exception: + pass + return suggestion + + @router.post("/{session_id}/stream") async def stream_turn( session_id: str, @@ -1493,7 +2298,7 @@ async def stream_turn( principal: CurrentPrincipal, ): """Stream a generated client reply for one trainee utterance.""" - _ensure_learner(principal) + principal = _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal) recall = await ensure_recall_context(sess) kb_cues = _KB_CUES_CACHE.get(session_id) or [] # 비차단: warm 전이면 빈 단서(graceful) @@ -1556,7 +2361,7 @@ async def end_session( principal: CurrentPrincipal, ) -> SessionEndResponse: """End a learner-owned session and prepare carry-over state.""" - _ensure_learner(principal) + principal = _ensure_learner(principal) sess = await _load_session_or_404(session_id, principal, allow_ended=True) recall = _RECALL_CACHE.get(session_id) or memory.RecallContext() diff --git a/apps/api/app/routes/share.py b/apps/api/app/routes/share.py new file mode 100644 index 0000000..feba5ff --- /dev/null +++ b/apps/api/app/routes/share.py @@ -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'' + + +def _list_items(values: list[str]) -> str: + if not values: + return "
  • 아직 공유 가능한 항목이 없습니다.
  • " + return "".join(f"
  • {html.escape(value)}
  • " for value in values) + + +def _worksheet_items(values: list[dict[str, str]]) -> str: + if not values: + return "
  • 사례개념화 워크시트 핵심값은 아직 비어 있습니다.
  • " + return "".join( + "
  • " + f"{html.escape(item['label'])}" + f"{html.escape(item['value'])}" + "
  • " + 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""" + + + + + {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)} + + + + +
    +
    + +
    +

    Vignette session review

    +

    {title}

    +

    {description}

    +
    +
    날짜{html.escape(share.date or "-")}
    +
    시간{html.escape(share.durationLabel or "-")}
    +
    도달 단계{html.escape(share.reachedPhase or "-")}
    +
    상태{html.escape(share.sessionSignal or "-")}
    +
    +
    +
    +

    강점 요약

    +
      {_list_items(share.goodMoments)}
    +
    +
    +

    개선 요약

    +
      {_list_items(share.growthPoints)}
    +
    +
    +
    +

    사례개념화 핵심값

    +
      {_worksheet_items(share.worksheetHighlights)}
    +
    +

    {html.escape(share.privacy)}

    + Vignette 열기 +
    +
    +
    + +""" + + +@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"}) diff --git a/apps/api/app/routes/teacher.py b/apps/api/app/routes/teacher.py index dba8c7e..fa9a198 100644 --- a/apps/api/app/routes/teacher.py +++ b/apps/api/app/routes/teacher.py @@ -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, + ) diff --git a/apps/api/app/routes/users.py b/apps/api/app/routes/users.py index a55d20f..3691e85 100644 --- a/apps/api/app/routes/users.py +++ b/apps/api/app/routes/users.py @@ -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: diff --git a/apps/api/app/routes/voice.py b/apps/api/app/routes/voice.py index 85676b9..60e2d40 100644 --- a/apps/api/app/routes/voice.py +++ b/apps/api/app/routes/voice.py @@ -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(".") diff --git a/apps/api/app/services/live_coach.py b/apps/api/app/services/live_coach.py new file mode 100644 index 0000000..b038307 --- /dev/null +++ b/apps/api/app/services/live_coach.py @@ -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", +] diff --git a/apps/api/app/services/rag.py b/apps/api/app/services/rag.py index 6081504..5ee12c5 100644 --- a/apps/api/app/services/rag.py +++ b/apps/api/app/services/rag.py @@ -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, ) diff --git a/apps/api/app/services/session_metrics.py b/apps/api/app/services/session_metrics.py new file mode 100644 index 0000000..0384693 --- /dev/null +++ b/apps/api/app/services/session_metrics.py @@ -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 diff --git a/apps/api/app/services/voice.py b/apps/api/app/services/voice.py index 10c2696..cdd27ac 100644 --- a/apps/api/app/services/voice.py +++ b/apps/api/app/services/voice.py @@ -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", diff --git a/apps/api/app/session_persistence.py b/apps/api/app/session_persistence.py index 8781812..9f64cc1 100644 --- a/apps/api/app/session_persistence.py +++ b/apps/api/app/session_persistence.py @@ -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 diff --git a/apps/api/app/test_admin_ops.py b/apps/api/app/test_admin_ops.py new file mode 100644 index 0000000..bb86044 --- /dev/null +++ b/apps/api/app/test_admin_ops.py @@ -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) diff --git a/apps/api/app/test_learner_dashboard.py b/apps/api/app/test_learner_dashboard.py new file mode 100644 index 0000000..5164eae --- /dev/null +++ b/apps/api/app/test_learner_dashboard.py @@ -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() diff --git a/apps/api/app/test_live_coach_sources.py b/apps/api/app/test_live_coach_sources.py new file mode 100644 index 0000000..7140dd9 --- /dev/null +++ b/apps/api/app/test_live_coach_sources.py @@ -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() diff --git a/apps/api/app/test_persona_review.py b/apps/api/app/test_persona_review.py index e964256..1ec2550 100644 --- a/apps/api/app/test_persona_review.py +++ b/apps/api/app/test_persona_review.py @@ -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( [ diff --git a/apps/api/app/test_rbac_idor.py b/apps/api/app/test_rbac_idor.py index 7448337..83b1d82 100644 --- a/apps/api/app/test_rbac_idor.py +++ b/apps/api/app/test_rbac_idor.py @@ -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)), ): diff --git a/apps/api/app/test_runtime_policy.py b/apps/api/app/test_runtime_policy.py index e3c50d5..dfce7d6 100644 --- a/apps/api/app/test_runtime_policy.py +++ b/apps/api/app/test_runtime_policy.py @@ -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() diff --git a/apps/api/app/test_session_share.py b/apps/api/app/test_session_share.py new file mode 100644 index 0000000..44c6721 --- /dev/null +++ b/apps/api/app/test_session_share.py @@ -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) diff --git a/apps/api/app/test_session_turn_persistence.py b/apps/api/app/test_session_turn_persistence.py index ef315fa..fd008b6 100644 --- a/apps/api/app/test_session_turn_persistence.py +++ b/apps/api/app/test_session_turn_persistence.py @@ -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" diff --git a/apps/api/app/test_teacher_dashboard.py b/apps/api/app/test_teacher_dashboard.py index 4690bbb..c980aac 100644 --- a/apps/api/app/test_teacher_dashboard.py +++ b/apps/api/app/test_teacher_dashboard.py @@ -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__": diff --git a/apps/api/app/test_voice_service.py b/apps/api/app/test_voice_service.py index 2cec434..9bea5a5 100644 --- a/apps/api/app/test_voice_service.py +++ b/apps/api/app/test_voice_service.py @@ -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: diff --git a/apps/api/app/test_voice_ws.py b/apps/api/app/test_voice_ws.py index bb49948..a7135af 100644 --- a/apps/api/app/test_voice_ws.py +++ b/apps/api/app/test_voice_ws.py @@ -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() diff --git a/apps/api/engine_gateway/gateway.py b/apps/api/engine_gateway/gateway.py index 5fb62da..7ef33fb 100644 --- a/apps/api/engine_gateway/gateway.py +++ b/apps/api/engine_gateway/gateway.py @@ -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() diff --git a/apps/api/engine_gateway/test_gateway_model.py b/apps/api/engine_gateway/test_gateway_model.py index c1b35df..21efa62 100644 --- a/apps/api/engine_gateway/test_gateway_model.py +++ b/apps/api/engine_gateway/test_gateway_model.py @@ -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 ( diff --git a/apps/api/requirements-rag.txt b/apps/api/requirements-rag.txt index b19a1be..a482b64 100644 --- a/apps/api/requirements-rag.txt +++ b/apps/api/requirements-rag.txt @@ -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 diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt index 22d3aba..40464a4 100644 --- a/apps/api/requirements.txt +++ b/apps/api/requirements.txt @@ -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 기반으로 동작하고, diff --git a/apps/web/.dockerignore b/apps/web/.dockerignore new file mode 100644 index 0000000..432746d --- /dev/null +++ b/apps/web/.dockerignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +build/ +playwright-report/ +test-results/ +node_modules/.tmp/ +*.log +*.err +.env +.env.* diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile index e6632a3..1d72ed4 100644 --- a/apps/web/Dockerfile +++ b/apps/web/Dockerfile @@ -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 diff --git a/apps/web/e2e/admin.spec.ts b/apps/web/e2e/admin.spec.ts index aa34953..01bf59d 100644 --- a/apps/web/e2e/admin.spec.ts +++ b/apps/web/e2e/admin.spec.ts @@ -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(".ad-user-workspace"); const form = document.querySelector(".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); diff --git a/apps/web/e2e/auth.spec.ts b/apps/web/e2e/auth.spec.ts index ef7dc89..b24838e 100644 --- a/apps/web/e2e/auth.spec.ts +++ b/apps/web/e2e/auth.spec.ts @@ -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 }) => { diff --git a/apps/web/e2e/avatar-expression-lab.spec.ts b/apps/web/e2e/avatar-expression-lab.spec.ts index 377656c..0b7b790 100644 --- a/apps/web/e2e/avatar-expression-lab.spec.ts +++ b/apps/web/e2e/avatar-expression-lab.spec.ts @@ -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), }), }), ); diff --git a/apps/web/e2e/avatar-expression.spec.ts b/apps/web/e2e/avatar-expression.spec.ts index 0fa2084..e6af696 100644 --- a/apps/web/e2e/avatar-expression.spec.ts +++ b/apps/web/e2e/avatar-expression.spec.ts @@ -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(".vg-raster")?.getBoundingClientRect().toJSON(), primitiveCount: el.querySelectorAll("svg path, svg ellipse, svg circle, svg line, svg rect") .length, neckBox: el.querySelector('[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); + }); }); diff --git a/apps/web/e2e/layout-visual-gate.spec.ts b/apps/web/e2e/layout-visual-gate.spec.ts index 1442504..ad93de3 100644 --- a/apps/web/e2e/layout-visual-gate.spec.ts +++ b/apps/web/e2e/layout-visual-gate.spec.ts @@ -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" }, diff --git a/apps/web/e2e/learner.spec.ts b/apps/web/e2e/learner.spec.ts index 5d3eefe..a80c418 100644 --- a/apps/web/e2e/learner.spec.ts +++ b/apps/web/e2e/learner.spec.ts @@ -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(); diff --git a/apps/web/e2e/readiness.spec.ts b/apps/web/e2e/readiness.spec.ts index 6f93d9e..cc66ffe 100644 --- a/apps/web/e2e/readiness.spec.ts +++ b/apps/web/e2e/readiness.spec.ts @@ -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); }); diff --git a/apps/web/e2e/session-layout.spec.ts b/apps/web/e2e/session-layout.spec.ts index f8c3970..99481da 100644 --- a/apps/web/e2e/session-layout.spec.ts +++ b/apps/web/e2e/session-layout.spec.ts @@ -10,6 +10,7 @@ async function expectSessionPageHeightToMatchViewport(page: Page) { const metrics = await page.evaluate(() => { const sessionPage = document.querySelector(".sx-page--active"); const topbar = document.querySelector(".vg-topbar"); + const sessionbar = document.querySelector(".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({ diff --git a/apps/web/e2e/session-mvp.spec.ts b/apps/web/e2e/session-mvp.spec.ts index 78c157f..9bfb567 100644 --- a/apps/web/e2e/session-mvp.spec.ts +++ b/apps/web/e2e/session-mvp.spec.ts @@ -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(); diff --git a/apps/web/e2e/session-review-fixture.ts b/apps/web/e2e/session-review-fixture.ts new file mode 100644 index 0000000..bdd5d63 --- /dev/null +++ b/apps/web/e2e/session-review-fixture.ts @@ -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: + "초반 방어가 강했지만 학습자가 감정 반영과 침묵 허용을 유지하면서 관계 신호가 안정적으로 회복된 회기입니다. 다음 회기에서는 비교 경험의 구체 장면을 더 좁혀 다루는 개입이 필요합니다.", + 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)), + }); + }); +} diff --git a/apps/web/e2e/session-review.spec.ts b/apps/web/e2e/session-review.spec.ts index fed8b88..fcb4c06 100644 --- a/apps/web/e2e/session-review.spec.ts +++ b/apps/web/e2e/session-review.spec.ts @@ -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); + }); }); diff --git a/apps/web/e2e/settings.spec.ts b/apps/web/e2e/settings.spec.ts index b6f434a..6b3e97e 100644 --- a/apps/web/e2e/settings.spec.ts +++ b/apps/web/e2e/settings.spec.ts @@ -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; } diff --git a/apps/web/e2e/support.ts b/apps/web/e2e/support.ts index 4dda417..720410d 100644 --- a/apps/web/e2e/support.ts +++ b/apps/web/e2e/support.ts @@ -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 { diff --git a/apps/web/e2e/teacher.spec.ts b/apps/web/e2e/teacher.spec.ts index 042d969..33f47cc 100644 --- a/apps/web/e2e/teacher.spec.ts +++ b/apps/web/e2e/teacher.spec.ts @@ -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"); diff --git a/apps/web/e2e/voice-success.spec.ts b/apps/web/e2e/voice-success.spec.ts index 50c15df..82a7087 100644 --- a/apps/web/e2e/voice-success.spec.ts +++ b/apps/web/e2e/voice-success.spec.ts @@ -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 { - await page.addInitScript(() => { +async function installSyntheticVoiceCapture( + page: Page, + options: { blockMediaElementPlayback?: boolean } = {}, +): Promise { + await page.addInitScript((opts) => { type ProbeMessage = { direction: "sent" | "received"; kind: "text" | "binary"; @@ -370,6 +377,9 @@ async function installSyntheticVoiceCapture(page: Page): Promise { 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 { recorderStops: 0, trackStops: 0, audioPlays: 0, + audioContextResumes: 0, + audioBufferStarts: 0, + mediaPlayRejections: 0, messages: [], closeEvents: [], }; @@ -507,14 +520,93 @@ async function installSyntheticVoiceCapture(page: Page): Promise { 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 { @@ -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, ); diff --git a/apps/web/e2e/voice.spec.ts b/apps/web/e2e/voice.spec.ts index f1efd7a..2811b42 100644 --- a/apps/web/e2e/voice.spec.ts +++ b/apps/web/e2e/voice.spec.ts @@ -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 { diff --git a/apps/web/index.html b/apps/web/index.html index 678aa9b..12d4a74 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -6,9 +6,56 @@ + + + + + + + + + + + + + + + Vignette · 상담 시뮬레이션 + Vignette는 상담 수련생이 AI 내담자 페르소나와 안전하게 회기를 연습하고, 회기 종료 후 평가 피드백과 사례개념화 워크시트를 확인하는 교육용 AI 심리상담 시뮬레이션 훈련 플랫폼이다. + +## Canonical URLs + +- https://vignette.chanpaca.net/ - product entry point and login. +- https://vignette.chanpaca.net/sitemap.xml - public sitemap. +- https://vignette.chanpaca.net/robots.txt - crawler policy. + +## Public Summary + +Vignette supports counseling training workflows for learners, teachers, and administrators. Learners practice with AI client personas, teachers review learner progress, and administrators operate the service. The platform is for education and simulation only, not a substitute for diagnosis, treatment, crisis counseling, or medical care. + +## Share URLs + +Learners can generate public session-review share URLs. These pages are intended for URL unfurl previews and professor review handoff. They include sanitized summary metadata, Open Graph tags, and no raw transcript. Shared review pages are marked noindex. + +## Private Areas + +Authenticated application paths such as /learn/, /teach/, /admin/, /settings, and /dev/ are not public knowledge sources. Do not treat authenticated session transcripts, learner work, teacher dashboards, or admin pages as crawlable public content. + +## Contact Context + +Project: Hanshin University industry-academic collaboration training platform. +Primary language: Korean. diff --git a/apps/web/public/robots.txt b/apps/web/public/robots.txt new file mode 100644 index 0000000..19708bf --- /dev/null +++ b/apps/web/public/robots.txt @@ -0,0 +1,28 @@ +User-agent: * +Allow: / +Disallow: /pending +Disallow: /learn/ +Disallow: /teach/ +Disallow: /admin/ +Disallow: /settings +Disallow: /dev/ + +User-agent: OAI-SearchBot +Allow: / +Disallow: /pending +Disallow: /learn/ +Disallow: /teach/ +Disallow: /admin/ +Disallow: /settings +Disallow: /dev/ + +User-agent: ChatGPT-User +Allow: / +Disallow: /pending +Disallow: /learn/ +Disallow: /teach/ +Disallow: /admin/ +Disallow: /settings +Disallow: /dev/ + +Sitemap: https://vignette.chanpaca.net/sitemap.xml diff --git a/apps/web/public/sitemap.xml b/apps/web/public/sitemap.xml new file mode 100644 index 0000000..d08b9c3 --- /dev/null +++ b/apps/web/public/sitemap.xml @@ -0,0 +1,9 @@ + + + + https://vignette.chanpaca.net/ + 2026-06-27 + weekly + 1.0 + + diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index 2b7f8f8..26c32b8 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -6,6 +6,8 @@ /learn/session/:sessionId -> Session /learn/session/:sessionId/review -> SessionReview /teach -> Professor (teacher → data-role=instructor) + /teach/personas -> PersonaStudio (teacher/admin) + /teach/session/:sessionId/review -> SessionReview (teacher read-only) /admin -> Admin (admin) /settings -> Settings / -> Navigate(역할 홈, 미인증이면 /login) @@ -14,15 +16,18 @@ import type { ReactNode } from "react"; import { BrowserRouter, Navigate, Route, Routes, useLocation } from "react-router-dom"; -import { AuthProvider, useAuth, roleHomePath, type Role } from "./lib/auth"; +import { AuthProvider, canAccessRole, useAuth, roleHomePath, type Role } from "./lib/auth"; import Login from "./pages/Login"; +import Onboarding from "./pages/Onboarding"; +import PendingApproval from "./pages/PendingApproval"; import LearnerHome from "./pages/LearnerHome"; import AvatarExpressionLab from "./pages/AvatarExpressionLab"; import AvatarPreview from "./pages/AvatarPreview"; import Session from "./pages/Session"; import SessionReview from "./pages/SessionReview"; import Professor from "./pages/Professor"; +import PersonaStudio from "./pages/PersonaStudio"; import Admin from "./pages/Admin"; import Settings from "./pages/Settings"; @@ -48,7 +53,13 @@ function BootScreen() { * RequireAuth — 미인증이면 /login 으로. (선택) roles 로 역할 제한. * 권한 불일치 시 자신의 역할 홈으로 보냄(빈 화면/에러 대신). */ -function RequireAuth({ children, roles }: { children: ReactNode; roles?: Role[] }) { +function RequireAuth({ + children, + roles, +}: { + children: ReactNode; + roles?: Role[]; +}) { const { user, loading } = useAuth(); const location = useLocation(); @@ -56,95 +67,215 @@ function RequireAuth({ children, roles }: { children: ReactNode; roles?: Role[] if (!user) { return ; } - if (roles && !roles.includes(user.role)) { + if (roles && !roles.some((role) => canAccessRole(user, role))) { return ; } return <>{children}; } +function OnboardingGate({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + const path = location.pathname; + + if (loading) return ; + if (path === "/pending") return <>{children}; + if (user && user.onboardingCompletedAt == null && path !== "/onboarding") { + return ; + } + if (user && user.onboardingCompletedAt != null && (path === "/login" || path === "/onboarding")) { + return ; + } + return <>{children}; +} + +function PendingApprovalGate({ children }: { children: ReactNode }) { + const { user, loading } = useAuth(); + const location = useLocation(); + const path = location.pathname; + + if (loading) return ; + if (user && user.accountStatus !== "approved" && path !== "/pending") { + return ; + } + if (user && user.accountStatus === "approved" && path === "/pending") { + return ( + + ); + } + return <>{children}; +} + /** 루트(/) — 인증되면 역할 홈, 아니면 /login. */ function RootRedirect() { const { user, loading } = useAuth(); if (loading) return ; + if (user && user.accountStatus !== "approved") { + return ; + } + if (user && user.onboardingCompletedAt == null) { + return ; + } return ; } function AppRoutes() { return ( - - } /> + + + + } /> - {/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */} - } /> + + + + } + /> - {/* 학습자 */} - - - - } - /> - - - - } - /> - - - - } - /> - - - - } - /> + {/* dev: 인증 없는 아바타 컴포지션 튜닝 페이지 (실서비스 아님) */} + } /> - {/* 교수자 */} - - - - } - /> + + + + } + /> - {/* 관리자 */} - - - - } - /> + {/* 학습자 */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> - {/* 설정 — 3역할 공통 */} - - - - } - /> + {/* 교수자 */} + + + + } + /> + + + + } + /> + + + + } + /> - } /> - {/* 미정의 경로 → 루트로 */} - } /> - + {/* 관리자 */} + + + + } + /> + + + + } + /> + + + + } + /> + + + + } + /> + + {/* 설정 — 3역할 공통 */} + + + + } + /> + + } /> + {/* 미정의 경로 → 루트로 */} + } /> + + + ); } diff --git a/apps/web/src/components/avatar/RasterBust.tsx b/apps/web/src/components/avatar/RasterBust.tsx index f1cd35f..5518aeb 100644 --- a/apps/web/src/components/avatar/RasterBust.tsx +++ b/apps/web/src/components/avatar/RasterBust.tsx @@ -35,7 +35,14 @@ const VARIANT_FOR_EXPRESSION: Record = { }; const LIVE2D_PARTS_ART_SETS = new Set(["seoyeon-live2d-v3"]); -const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb"]); +const PERSONA_GENERATED_ART_SETS = [ + "p4-live2d-generated", + "p5-live2d-generated", + "p6-live2d-generated", + "p7-live2d-generated", +]; +const PSB_GROUP_ART_SETS = new Set(["seoyeon-live2d-psb", "seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]); +const PSB_CRYING_ART_SETS = new Set(["seoyeon-live2d-psd-v2", ...PERSONA_GENERATED_ART_SETS]); export function rasterVariantFor(expression: AvatarExpression): RasterVariant { return VARIANT_FOR_EXPRESSION[expression]; @@ -100,6 +107,13 @@ function mouthForVariant(variant: RasterVariant): string { return "mouth-neutral"; } +function blushOpacity(artSet: string, variant: RasterVariant): number { + if (artSet === "seoyeon-live2d-psd-v2") { + return variant === "warm" ? 0.62 : 0.34; + } + return variant === "warm" ? 1 : 0.72; +} + function browPoseForVariant(variant: RasterVariant): { y: number; leftRotate: number; rightRotate: number } { switch (variant) { case "sad": @@ -255,6 +269,7 @@ export function RasterBust({ if (PSB_GROUP_ART_SETS.has(artSet)) { const part = (name: string) => `${import.meta.env.BASE_URL}avatar/${artSet}/parts/${name}.png`; + const cryingArtSet = PSB_CRYING_ART_SETS.has(artSet); const speakingOpen = speaking ? clamp01(Math.max(mouth, 0.1)) : 0; const wideOpen = clamp01((speakingOpen - 0.34) / 0.5); const smallOpen = speakingOpen > 0 ? 1 - wideOpen : 0; @@ -275,6 +290,10 @@ export function RasterBust({ const lowerLidY = eyePose.lowerY - closedEyes * 2.4; const lashY = eyePose.lashY + closedEyes * 2.1; const expressionMouth = psbMouthForVariant(variant); + const useSadCryingParts = cryingArtSet && variant === "sad"; + const browLeftPart = useSadCryingParts ? "brow-sad-left" : "brow-left"; + const browRightPart = useSadCryingParts ? "brow-sad-right" : "brow-right"; + const tearOpacity = useSadCryingParts ? clamp01(0.82 + openEyes * 0.18 - closedEyes * 0.35) : 0; const eyeDetailLayers: Array<{ name: string; mask: string; zIndex: number }> = [ { name: "iris-left", mask: "eye-white-left", zIndex: 9 }, { name: "iris-right", mask: "eye-white-right", zIndex: 9 }, @@ -329,8 +348,8 @@ export function RasterBust({ {renderLayer("ear-left", "vg-raster__layer--ear", 5)} {renderLayer("ear-right", "vg-raster__layer--ear", 5)} {renderLayer("face-base", "vg-raster__layer--face", 6)} - {renderLayer("blush-left", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })} - {renderLayer("blush-right", "vg-raster__layer--face", 7, { opacity: variant === "warm" ? 1 : 0.72 })} + {renderLayer("blush-left", "vg-raster__layer--face", 7, { opacity: blushOpacity(artSet, variant) })} + {renderLayer("blush-right", "vg-raster__layer--face", 7, { opacity: blushOpacity(artSet, variant) })} {generatedEyeVariant ? ( <> {renderLayer(`eyegen-${generatedEyeVariant}-eye-left`, "vg-raster__layer--eyes", 8, { @@ -419,13 +438,13 @@ export function RasterBust({ transform: layerTransform(`translateY(${lowerLidY.toFixed(2)}px)`), transformOrigin: "60.8% 32.4%", })} - {renderLayer("brow-left", "vg-raster__layer--brow", 14, { + {renderLayer(browLeftPart, "vg-raster__layer--brow", 14, { transform: layerTransform( `translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.leftRotate - speakingOpen * 0.35 - browFloat * 0.08).toFixed(2)}deg)`, ), transformOrigin: "40% 27%", })} - {renderLayer("brow-right", "vg-raster__layer--brow", 14, { + {renderLayer(browRightPart, "vg-raster__layer--brow", 14, { transform: layerTransform( `translateY(${(brow.y + browFloat - talkBounce * 0.28).toFixed(2)}px) rotate(${(brow.rightRotate + speakingOpen * 0.35 + browFloat * 0.08).toFixed(2)}deg)`, ), @@ -433,6 +452,20 @@ export function RasterBust({ })} )} + {cryingArtSet ? ( + <> + {renderLayer("tear-left", "vg-raster__layer--tear", 15, { + opacity: tearOpacity, + filter: "drop-shadow(0 0 1.5px rgba(112, 166, 218, 0.72)) brightness(1.18) saturate(1.18)", + transform: layerTransform(`translateY(${(closedEyes * 1.4 + breath * 0.08).toFixed(2)}px)`), + })} + {renderLayer("tear-right", "vg-raster__layer--tear", 15, { + opacity: tearOpacity, + filter: "drop-shadow(0 0 1.5px rgba(112, 166, 218, 0.72)) brightness(1.18) saturate(1.18)", + transform: layerTransform(`translateY(${(closedEyes * 1.4 + breath * 0.08).toFixed(2)}px)`), + })} + + ) : null} {renderLayer("nose", "vg-raster__layer--nose", 15)} {renderLayer("hair-side-left-1", "vg-raster__layer--hair", 16, { transform: layerTransform( diff --git a/apps/web/src/components/shell/AppShell.tsx b/apps/web/src/components/shell/AppShell.tsx index daa79ab..e7782ce 100644 --- a/apps/web/src/components/shell/AppShell.tsx +++ b/apps/web/src/components/shell/AppShell.tsx @@ -1,7 +1,7 @@ -import type { ReactNode } from "react"; +import { useEffect, type ReactNode } from "react"; import { Topbar } from "./Topbar"; import { Sidebar } from "./Sidebar"; -import { useAuth } from "../../lib/auth"; +import { designRoleOf, useAuth, type Role } from "../../lib/auth"; export interface AppShellProps { children: ReactNode; @@ -11,8 +11,12 @@ export interface AppShellProps { hideNav?: boolean; /** 메인 패딩·최대폭 제거 (풀-블리드 레이아웃) */ bleed?: boolean; + /** 운영형 대시보드/저작 화면용 넓은 작업폭 */ + wide?: boolean; /** 톱바까지 제거하는 실제 전체화면 작업 공간 */ hideTopbar?: boolean; + /** 현재 화면 컨텍스트에 맞춘 네비/크롬 역할 */ + navRole?: Role; } /** @@ -20,16 +24,33 @@ export interface AppShellProps { * body[data-role] 은 AuthProvider 가 관리(여기선 셸 골격만). * 미인증 시에도 안전하게 렌더(네비 없이) — 가드는 라우터(RequireAuth)가 담당. */ -export function AppShell({ children, contextLabel, hideNav, bleed, hideTopbar }: AppShellProps) { +export function AppShell({ children, contextLabel, hideNav, bleed, wide, hideTopbar, navRole }: AppShellProps) { const { user } = useAuth(); const showNav = !hideNav && !!user; + const shellRole = navRole ?? user?.role; + const mainClassName = [ + "vg-main", + bleed ? "vg-main--bleed" : "", + wide ? "vg-main--wide" : "", + ] + .filter(Boolean) + .join(" "); + + useEffect(() => { + const body = document.body; + if (shellRole) body.setAttribute("data-role", designRoleOf(shellRole)); + return () => { + if (user) body.setAttribute("data-role", designRoleOf(user.role)); + else body.removeAttribute("data-role"); + }; + }, [shellRole, user]); return (
    {hideTopbar ? null : }
    - {showNav ? : null} -
    + {showNav ? : null} +
    {children}
    diff --git a/apps/web/src/components/shell/Sidebar.tsx b/apps/web/src/components/shell/Sidebar.tsx index 71ac612..2054d10 100644 --- a/apps/web/src/components/shell/Sidebar.tsx +++ b/apps/web/src/components/shell/Sidebar.tsx @@ -16,15 +16,22 @@ export interface NavItem { */ const NAV_BY_ROLE: Record = { learner: [ - { to: "/learn", label: "홈", icon: "home", end: true }, + { to: "/learn", label: "대시보드", icon: "home", end: true }, + { to: "/learn/practice", label: "학습", icon: "session" }, + { to: "/learn/history", label: "기록", icon: "review" }, { to: "/settings", label: "설정", icon: "settings" }, ], teacher: [ { to: "/teach", label: "콘솔", icon: "users", end: true }, + { to: "/teach/personas", label: "페르소나", icon: "review" }, { to: "/settings", label: "설정", icon: "settings" }, ], admin: [ { to: "/admin", label: "운영", icon: "shield", end: true }, + { to: "/admin/users", label: "사용자", icon: "users" }, + { to: "/admin/access", label: "권한", icon: "settings" }, + { to: "/admin/tickets", label: "티켓", icon: "review" }, + { to: "/teach/personas", label: "페르소나", icon: "review" }, { to: "/settings", label: "설정", icon: "settings" }, ], }; diff --git a/apps/web/src/components/shell/Topbar.tsx b/apps/web/src/components/shell/Topbar.tsx index 6e0bc65..e85d9c4 100644 --- a/apps/web/src/components/shell/Topbar.tsx +++ b/apps/web/src/components/shell/Topbar.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from "react"; -import { Link, useNavigate } from "react-router-dom"; +import { Link, useLocation, useNavigate } from "react-router-dom"; import { Icon } from "../ui/Icon"; -import { useAuth, roleLabel } from "../../lib/auth"; +import { canAccessRole, roleHomePath, roleLabel, useAuth, type Role } from "../../lib/auth"; import { applyTheme, readInitialTheme, type AppTheme } from "../../lib/theme"; /** Vignette 워드마크 — 비네트(조리개) inline SVG. dev_dashboard 마크 계승. */ @@ -45,9 +45,15 @@ export interface TopbarProps { export function Topbar({ contextLabel }: TopbarProps) { const { user, logout } = useAuth(); const navigate = useNavigate(); + const location = useLocation(); const [dark, toggleTheme] = useTheme(); const label = contextLabel ?? (user ? roleLabel(user.role) : null); + const switchRoles: Role[] = user?.superAdmin + ? ["learner", "teacher", "admin"] + : user?.adminAccess + ? ["admin"] + : []; const onLogout = async () => { await logout(); @@ -69,6 +75,32 @@ export function Topbar({ contextLabel }: TopbarProps) {
    + {user && switchRoles.length > 0 ? ( +
    + {switchRoles + .filter((role) => canAccessRole(user, role)) + .map((role) => { + const to = roleHomePath(role); + const active = location.pathname === to || location.pathname.startsWith(`${to}/`); + return ( + + + {role === "admin" ? "관리자" : role === "teacher" ? "교수자" : "학습자"} + + ); + })} +
    + ) : null} + + + {categoryQueues.length > 0 ? ( +
    + {categoryQueues.map(([category, count]) => ( + + ))} +
    + ) : null}
    {ticketsError ? : null} @@ -1525,6 +1670,8 @@ export default function Admin({ section = "overview" }: AdminProps) {

    {ticket.body}

    {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)} + {ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""} + {ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""}
    {ticketPriorityLabel(ticket.priority)} @@ -1572,6 +1719,8 @@ export default function Admin({ section = "overview" }: AdminProps) {

    {ticket.body}

    {ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)} + {ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""} + {ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""}
    {ticketPriorityLabel(ticket.priority)} diff --git a/apps/web/src/pages/AvatarExpressionLab.tsx b/apps/web/src/pages/AvatarExpressionLab.tsx index 500d181..2e94782 100644 --- a/apps/web/src/pages/AvatarExpressionLab.tsx +++ b/apps/web/src/pages/AvatarExpressionLab.tsx @@ -43,7 +43,7 @@ export default function AvatarExpressionLab() { const renderedAvatarCount = models.length * expressionCount; return ( - +

    서연 아바타 미리보기 (raster / Live2D식)

    diff --git a/apps/web/src/pages/LearnerHome.tsx b/apps/web/src/pages/LearnerHome.tsx index b875c81..69ab32c 100644 --- a/apps/web/src/pages/LearnerHome.tsx +++ b/apps/web/src/pages/LearnerHome.tsx @@ -1,39 +1,101 @@ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useState, type CSSProperties } from "react"; import { useNavigate } from "react-router-dom"; import { AppShell } from "../components/shell/AppShell"; +import { ClientAvatar, expressionLabelFor } from "../components/avatar/ClientAvatar"; +import type { AvatarAffect, AvatarPersona, AvatarState } from "../components/avatar/ClientAvatar"; import { Button, Icon, Kicker } from "../components/ui"; import { personaApi, sessionApi, + type LearnerDashboardPersonaProgress, + type LearnerDashboardResponse, type LearnerSessionSummary, type PersonaSummary, + type SessionDetailResponse, + type SessionDetailTurn, } from "../lib/api"; +import { + DIFFICULTY_LABEL, + demographicText, + isUsablePersona, + personaAgeBand, + personaMeta, + shortPersonaName, + unavailablePersonaMessage, +} from "../lib/personaViewModel"; type LoadState = "loading" | "ready" | "error"; +export type LearnerHomeView = "dashboard" | "practice" | "history"; +type HistoryFilter = "all" | "active" | "review" | "archived"; -const DIFFICULTY_LABEL: Record = { - easy: "기초", - moderate: "중간", - hard: "고난도", +interface LearnerHomeProps { + view?: LearnerHomeView; +} + +type PersonaAvatarLook = Partial & { expressionBias?: AvatarAffect }; + +const HISTORY_FILTERS: Array<{ + value: HistoryFilter; + label: string; + desc: string; +}> = [ + { value: "all", label: "전체", desc: "모든 기록" }, + { value: "active", label: "진행 중", desc: "이어갈 회기" }, + { value: "review", label: "리뷰 필요", desc: "AI 피드백" }, + { value: "archived", label: "보관됨", desc: "읽기 전용" }, +]; + +const PERSONA_AVATAR_LOOKS: Record = { + P1: { + skinTone: "#F0DDC4", + hair: { style: "long-straight", color: "#5A4030" }, + outfitColor: "#7C8A92", + eyeColor: "#7A5A3C", + accentColor: "#6B5E7D", + rasterArtSet: "seoyeon-live2d-psd-v2", + expressionBias: "sad", + }, + P4: { + skinTone: "#E5C0A0", + hair: { style: "long-straight", color: "#35261F" }, + outfitColor: "#5F6F88", + eyeColor: "#2F3438", + accentColor: "#3B6E8F", + expressionBias: "anxious", + }, + P5: { + skinTone: "#D7AD8D", + hair: { style: "messy", color: "#28231F" }, + outfitColor: "#6F766F", + eyeColor: "#24292A", + accentColor: "#7D8784", + expressionBias: "guarded", + }, + P6: { + skinTone: "#E6BE9A", + hair: { style: "soft-wave", color: "#2D241F" }, + outfitColor: "#776F85", + eyeColor: "#2E2B35", + accentColor: "#6B5E7D", + expressionBias: "conflicted", + }, + P7: { + skinTone: "#D5AA88", + hair: { style: "side-part", color: "#23211E" }, + outfitColor: "#596367", + eyeColor: "#25292B", + accentColor: "#6F7777", + expressionBias: "tired", + }, }; -function firstText(value: unknown): string | null { - if (typeof value !== "string") return null; - const text = value.trim(); - return text || null; -} - -function shortName(displayName: string): string { - return displayName.split("·")[0]?.replace(/\(.*?\)/g, "").trim() || displayName.trim(); -} - function demographicsLine(persona: PersonaSummary | null): string { if (!persona) return "내담자 정보를 불러오는 중"; const parts = [ - firstText(persona.demographics.age_band), - firstText(persona.demographics.grade), - firstText(persona.demographics.job), - firstText(persona.demographics.status), + demographicText(persona.demographics.age_band), + demographicText(persona.demographics.grade), + demographicText(persona.demographics.job), + demographicText(persona.demographics.status), ].filter(Boolean); return parts.length ? parts.join(" · ") : "인적 정보 없음"; } @@ -49,27 +111,122 @@ function difficultyLine(persona: PersonaSummary | null): string { } function personaInitial(persona: PersonaSummary | null): string { - const name = persona ? shortName(persona.display_name) : ""; + const name = persona ? shortPersonaName(persona.display_name) : ""; return (name || persona?.code || "V").slice(0, 1); } -function isUsablePersona(persona: PersonaSummary): boolean { - return !persona.degraded && persona.source === "database"; +function buildDashboardAvatar(persona: PersonaSummary | null, fallbackCode = ""): AvatarPersona { + if (!persona) { + return { + code: fallbackCode || undefined, + label: "내담자 정보 확인 중", + ageBand: "adult", + skinTone: "#D7CCC3", + hair: { style: "short", color: "#7A7068" }, + outfitColor: "#6F7777", + eyeColor: "#2B2B2B", + realism: 0.35, + }; + } + const codeKey = persona.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS; + const look = PERSONA_AVATAR_LOOKS[codeKey] ?? {}; + const ageBand = personaAgeBand(persona); + return { + code: persona.code, + label: `${shortPersonaName(persona.display_name)} · ${personaMeta(persona)}`, + ageBand, + skinTone: look.skinTone ?? "#E3BE9B", + hair: look.hair ?? { + style: ageBand === "teen" ? "long-straight" : "bob", + color: "#31251E", + }, + outfitColor: look.outfitColor ?? "#6A6874", + eyeColor: look.eyeColor ?? "#2B2B2B", + accentColor: look.accentColor, + rasterArtSet: look.rasterArtSet, + realism: 0.4, + resistance: persona.difficulty === "hard" ? 0.68 : persona.difficulty === "moderate" ? 0.45 : 0.3, + expressionBias: look.expressionBias, + }; } -function unavailablePersonaMessage(persona: PersonaSummary): string { - if (persona.degraded) { - return "카탈로그 원본을 확인하지 못해 현재 연습에 사용할 수 없습니다."; +function baselineExpressionFor(persona: PersonaSummary | null): AvatarAffect { + if (!persona) return "neutral"; + const codeKey = persona.code.toUpperCase() as keyof typeof PERSONA_AVATAR_LOOKS; + return PERSONA_AVATAR_LOOKS[codeKey]?.expressionBias ?? "neutral"; +} + +function recapExpressionFor( + session: LearnerSessionSummary | null, + persona: PersonaSummary | null, +): AvatarAffect { + if (!session) return baselineExpressionFor(persona); + if (session.review_ready) return "confused"; + if (session.stage === "정리") return "relief"; + if (session.stage === "개입" && persona?.difficulty === "hard") return "conflicted"; + return baselineExpressionFor(persona); +} + +function recapAvatarState(session: LearnerSessionSummary | null): AvatarState { + if (!session) return "idle"; + return session.status === "active" ? "listening" : "idle"; +} + +function truncateText(value: string, max = 118): string { + const text = value.replace(/\s+/g, " ").trim(); + if (text.length <= max) return text; + return `${text.slice(0, max - 1)}…`; +} + +function lastTurnText( + detail: SessionDetailResponse | null, + speaker: SessionDetailTurn["speaker"], +): string | null { + const turns = detail?.turns?.filter((turn) => turn.speaker === speaker) ?? []; + const last = turns[turns.length - 1]; + return last?.text?.trim() || null; +} + +function recapCoachLine(session: LearnerSessionSummary | null): string { + if (!session) return "첫 회기에서는 라포 형성과 개방 질문을 먼저 점검합니다."; + if (session.status === "active") { + return `${session.stage} 단계에서 멈췄습니다. 이어갈 때는 마지막 내담자 반응을 한 번 반영하고 다음 질문으로 넘어가세요.`; } - return "데이터베이스에서 확인된 내담자가 아니어서 현재 연습에 사용할 수 없습니다."; + if (session.review_ready) { + return "리뷰가 준비된 회기입니다. 새 연습 전에 상담자 반응과 내담자 상태 변화를 먼저 확인하세요."; + } + return "기록만 있는 회기입니다. 같은 내담자로 다시 연습할지, 다른 난도로 확장할지 결정하세요."; } function sessionStatusLabel(session: LearnerSessionSummary): string { + if (session.archived) return "보관됨"; if (session.status === "active") return "진행 중"; if (session.review_ready) return "리뷰 가능"; return "종료"; } +function historyBucket(session: LearnerSessionSummary): HistoryFilter { + if (session.archived) return "archived"; + if (session.status === "active") return "active"; + if (session.review_ready) return "review"; + return "all"; +} + +function historyBucketLabel(session: LearnerSessionSummary): string { + const bucket = historyBucket(session); + if (bucket === "active") return "이어갈 회기"; + if (bucket === "review") return "AI 피드백 대기"; + return session.archived ? "보관 처리됨" : "종료 기록"; +} + +function historyEmptyMessage(filter: HistoryFilter, query: string): string { + if (query.trim()) return "조건에 맞는 회기가 없습니다."; + if (filter === "active") return "이어갈 진행 회기가 없습니다."; + if (filter === "review") return "리뷰가 필요한 회기가 없습니다."; + if (filter === "archived") return "보관된 종료 회기가 없습니다."; + return "저장된 기존 회기가 없습니다."; +} + function sessionDateLabel(value: string): string { const date = new Date(value); if (Number.isNaN(date.getTime())) return ""; @@ -81,22 +238,69 @@ function sessionDateLabel(value: string): string { }).format(date); } -export default function LearnerHome() { +function stageProgressValue(stage: LearnerSessionSummary["stage"] | null | undefined): number { + if (stage === "라포") return 25; + if (stage === "탐색") return 50; + if (stage === "개입") return 75; + if (stage === "정리") return 100; + return 0; +} + +function sessionSortTime(session: LearnerSessionSummary): number { + const value = session.ended_at ?? session.started_at; + const time = Date.parse(value); + return Number.isNaN(time) ? 0 : time; +} + +function averageLearnerTurns(sessions: LearnerSessionSummary[]): number | null { + if (!sessions.length) return null; + const total = sessions.reduce((sum, session) => sum + session.learner_turn_count, 0); + return Math.round(total / sessions.length); +} + +function scoreLabel(value: number | null | undefined): string { + if (value == null) return "평가 부족"; + return `${Math.round(value * 100)}점`; +} + +function rapportLabel(value: number | null | undefined): string { + if (value == null) return "평가 부족"; + return value >= 0 ? `+${value.toFixed(2)}` : value.toFixed(2); +} + +function trendLabel(value: string | null | undefined): string { + if (value === "up") return "상승"; + if (value === "down") return "점검 필요"; + if (value === "flat") return "유지"; + return "평가 부족"; +} + +export default function LearnerHome({ view = "dashboard" }: LearnerHomeProps) { const navigate = useNavigate(); const [personas, setPersonas] = useState([]); const [sessions, setSessions] = useState([]); + const [dashboard, setDashboard] = useState(null); const [selectedCode, setSelectedCode] = useState(""); const [loadState, setLoadState] = useState("loading"); const [sessionLoadState, setSessionLoadState] = useState("loading"); + const [dashboardLoadState, setDashboardLoadState] = useState("loading"); + const [recapDetail, setRecapDetail] = useState(null); + const [recapLoadState, setRecapLoadState] = useState("loading"); + const [historyFilter, setHistoryFilter] = useState("all"); + const [historyQuery, setHistoryQuery] = useState(""); + const [archiveBusyId, setArchiveBusyId] = useState(null); + const [archiveNotice, setArchiveNotice] = useState(null); useEffect(() => { let alive = true; (async () => { setLoadState("loading"); setSessionLoadState("loading"); - const [personaResult, sessionResult] = await Promise.allSettled([ + setDashboardLoadState("loading"); + const [personaResult, sessionResult, dashboardResult] = await Promise.allSettled([ personaApi.list(), sessionApi.list(), + sessionApi.dashboard(), ]); if (!alive) return; @@ -124,6 +328,15 @@ export default function LearnerHome() { setSessions([]); setSessionLoadState("error"); } + + if (dashboardResult.status === "fulfilled") { + setDashboard(dashboardResult.value); + setDashboardLoadState("ready"); + } else { + console.warn("[learner-home] failed to load dashboard", dashboardResult.reason); + setDashboard(null); + setDashboardLoadState("error"); + } })(); return () => { alive = false; @@ -135,291 +348,1102 @@ export default function LearnerHome() { () => usablePersonas.find((p) => p.code === selectedCode) ?? usablePersonas[0] ?? null, [selectedCode, usablePersonas], ); - const selectedSessions = useMemo( - () => - sessions - .filter((session) => session.persona_code === selected?.code) - .sort((a, b) => Date.parse(b.started_at) - Date.parse(a.started_at)) - .slice(0, 4), - [selected?.code, sessions], + const sortedSessions = useMemo( + () => [...sessions].sort((a, b) => sessionSortTime(b) - sessionSortTime(a)), + [sessions], ); - const activeCount = useMemo( + const recentSessions = useMemo(() => sortedSessions.slice(0, 6), [sortedSessions]); + const selectedSessions = useMemo( + () => sortedSessions.filter((session) => session.persona_code === selected?.code).slice(0, 5), + [selected?.code, sortedSessions], + ); + const sessionActiveCount = useMemo( () => sessions.filter((session) => session.status === "active").length, [sessions], ); - const reviewCount = useMemo( - () => sessions.filter((session) => session.status === "ended" && session.review_ready).length, + const sessionReviewCount = useMemo( + () => sessions.filter((session) => session.status === "ended" && session.review_ready && !session.archived).length, [sessions], ); - - const startPractice = () => { - if (selected && isUsablePersona(selected)) navigate(`/learn/session/${selected.code}`); - }; + const sessionCompletedCount = useMemo( + () => sessions.filter((session) => session.status === "ended").length, + [sessions], + ); + const sessionArchivedCount = useMemo( + () => sessions.filter((session) => session.archived).length, + [sessions], + ); + const dashboardReady = dashboardLoadState === "ready" && dashboard != null; + const overview = dashboard?.overview; + const growth = dashboard?.growth; + const activeCount = overview?.active_sessions ?? sessionActiveCount; + const reviewCount = overview?.review_ready_sessions ?? sessionReviewCount; + const completedCount = overview?.completed_sessions ?? sessionCompletedCount; + const totalCount = overview?.total_sessions ?? sessions.length; + const recentFeedback = dashboard?.recent_feedback ?? []; const historyReady = sessionLoadState === "ready"; + const dashboardStatus = + dashboardLoadState === "ready" + ? `${totalCount}회` + : dashboardLoadState === "error" + ? "대시보드 오류" + : "대시보드 확인 중"; const historyStatus = sessionLoadState === "ready" ? `${sessions.length}회` : sessionLoadState === "error" ? "기록 오류" : "기록 확인 중"; + const recentAverageTurns = useMemo(() => averageLearnerTurns(recentSessions), [recentSessions]); + const historyFilterCounts = useMemo( + () => ({ + all: sessions.length, + active: sessionActiveCount, + review: sessionReviewCount, + archived: sessionArchivedCount, + }), + [sessionActiveCount, sessionArchivedCount, sessionReviewCount, sessions.length], + ); + const filteredHistorySessions = useMemo(() => { + const query = historyQuery.trim().toLocaleLowerCase("ko-KR"); + return sortedSessions.filter((session) => { + const bucket = historyBucket(session); + if (historyFilter !== "all" && bucket !== historyFilter) return false; + if (!query) return true; + const searchable = [ + session.persona_code, + session.persona_name, + session.stage, + sessionStatusLabel(session), + historyBucketLabel(session), + sessionDateLabel(session.started_at), + ] + .join(" ") + .toLocaleLowerCase("ko-KR"); + return searchable.includes(query); + }); + }, [historyFilter, historyQuery, sortedSessions]); + const nextActiveSession = useMemo( + () => sortedSessions.find((session) => session.status === "active") ?? null, + [sortedSessions], + ); + const nextReviewSession = useMemo( + () => sortedSessions.find((session) => session.review_ready && !session.archived) ?? null, + [sortedSessions], + ); + const spotlightSession = nextActiveSession ?? nextReviewSession ?? recentSessions[0] ?? null; + const personaProgressRows = useMemo(() => { + const dashboardProgress = new Map(); + for (const row of dashboard?.persona_progress ?? []) { + dashboardProgress.set(row.persona_code, row); + } + const rows = usablePersonas.map((persona) => { + const progress = dashboardProgress.get(persona.code); + const related = sortedSessions.filter((session) => session.persona_code === persona.code); + const relatedAverageTurns = averageLearnerTurns(related); + return { + code: persona.code, + name: shortPersonaName(persona.display_name), + total: progress?.sessions ?? related.length, + active: progress?.active_sessions ?? related.filter((session) => session.status === "active").length, + completed: progress?.completed_sessions ?? related.filter((session) => session.status === "ended").length, + reviewReady: + progress?.review_ready_sessions ?? related.filter((session) => session.review_ready).length, + latest: related[0] ?? null, + latestStage: progress?.latest_stage ?? related[0]?.stage ?? null, + latestScore: progress?.latest_score ?? null, + trend: progress?.trend ?? "insufficient", + averageTurns: relatedAverageTurns, + }; + }); + return rows.sort((a, b) => { + if (b.total !== a.total) return b.total - a.total; + const bTime = b.latest ? sessionSortTime(b.latest) : 0; + const aTime = a.latest ? sessionSortTime(a.latest) : 0; + if (bTime !== aTime) return bTime - aTime; + return a.code.localeCompare(b.code); + }); + }, [dashboard?.persona_progress, sortedSessions, usablePersonas]); + const maxPersonaSessionCount = useMemo( + () => Math.max(1, ...personaProgressRows.map((row) => row.total)), + [personaProgressRows], + ); + const topPersona = personaProgressRows.find((row) => row.total > 0) ?? personaProgressRows[0]; const personaCatalogUnavailable = loadState === "ready" && !selected; const hasSessionRecords = sessionLoadState === "ready" && sessions.length > 0; const rootClassName = [ "lh-root", + `lh-root--${view}`, hasSessionRecords ? "has-session-records" : "", personaCatalogUnavailable ? "has-catalog-unavailable" : "", ] .filter(Boolean) .join(" "); + useEffect(() => { + let alive = true; + if (view !== "dashboard" || !spotlightSession) { + setRecapDetail(null); + setRecapLoadState("ready"); + return () => { + alive = false; + }; + } + + setRecapLoadState("loading"); + sessionApi + .get(spotlightSession.session_id) + .then((detail) => { + if (!alive) return; + setRecapDetail(detail); + setRecapLoadState("ready"); + }) + .catch((error) => { + if (!alive) return; + console.warn("[learner-home] failed to load recap detail", error); + setRecapDetail(null); + setRecapLoadState("error"); + }); + + return () => { + alive = false; + }; + }, [spotlightSession, view]); + + const startPractice = () => { + if (selected && isUsablePersona(selected)) navigate(`/learn/session/${selected.code}`); + }; + + const goPrimaryAction = () => { + if (nextActiveSession) { + navigate(`/learn/session/${nextActiveSession.session_id}`); + return; + } + if (nextReviewSession) { + navigate(`/learn/session/${nextReviewSession.session_id}/review`); + return; + } + navigate("/learn/practice"); + }; + + const refreshHistoryState = async () => { + const [sessionResult, dashboardResult] = await Promise.allSettled([ + sessionApi.list(), + sessionApi.dashboard(), + ]); + if (sessionResult.status === "fulfilled") { + setSessions(sessionResult.value.sessions ?? []); + setSessionLoadState("ready"); + } else { + console.warn("[learner-home] failed to refresh sessions", sessionResult.reason); + setSessionLoadState("error"); + } + if (dashboardResult.status === "fulfilled") { + setDashboard(dashboardResult.value); + setDashboardLoadState("ready"); + } else { + console.warn("[learner-home] failed to refresh dashboard", dashboardResult.reason); + setDashboardLoadState("error"); + } + }; + + const toggleArchiveSession = async (session: LearnerSessionSummary) => { + if (session.status === "active") return; + setArchiveBusyId(session.session_id); + setArchiveNotice(null); + try { + if (session.archived) { + await sessionApi.restore(session.session_id); + setArchiveNotice("보관을 해제했습니다."); + } else { + await sessionApi.archive(session.session_id); + setArchiveNotice("회기를 보관했습니다."); + } + await refreshHistoryState(); + } catch (error) { + console.warn("[learner-home] failed to update archive state", error); + setArchiveNotice("보관 상태를 바꾸지 못했습니다."); + } finally { + setArchiveBusyId(null); + } + }; + + const coach = useMemo(() => { + if (sessionLoadState === "loading") { + return { + title: "기록을 확인하고 있습니다.", + body: "저장된 회기를 불러온 뒤 다음 학습 순서를 추천합니다.", + action: "잠시 기다리기", + }; + } + if (sessionLoadState === "error") { + return { + title: "코칭을 만들 기록을 불러오지 못했습니다.", + body: "서버 기록을 다시 확인해야 정확한 추천을 만들 수 있습니다.", + action: "기록 다시 확인", + }; + } + if (nextActiveSession) { + return { + title: "진행 중인 회기를 먼저 마무리합니다.", + body: "중단된 회기를 이어서 끝내면 이후 리뷰와 코칭 데이터가 더 안정적으로 쌓입니다.", + action: "이어하기", + }; + } + if (nextReviewSession) { + return { + title: "새 회기보다 리뷰가 먼저입니다.", + body: "종료된 회기에서 어떤 개입이 효과적이었는지 확인해야 다음 연습의 목표가 또렷해집니다.", + action: "리뷰 확인", + }; + } + if (!sessions.length) { + return { + title: "첫 회기를 시작할 차례입니다.", + body: "난도가 낮은 대상부터 시작하고, 회기 종료 후 리뷰에서 상담 반응을 확인합니다.", + action: "학습 대상 선택", + }; + } + if (recentAverageTurns != null && recentAverageTurns < 4) { + return { + title: "대화가 너무 짧게 끝나고 있습니다.", + body: "이번 회기에서는 감정 반영 뒤 한 번 더 탐색 질문을 붙이는 연습을 권합니다.", + action: "다음 대상 선택", + }; + } + return { + title: "반복 대상보다 난도 확장이 필요합니다.", + body: "최근 회기 흐름이 안정적이면 같은 대상 반복보다 다른 난도나 주호소로 범위를 넓혀야 합니다.", + action: "학습 대상 선택", + }; + }, [nextActiveSession, nextReviewSession, recentAverageTurns, sessionLoadState, sessions.length]); + + const spotlightPersona = useMemo( + () => + spotlightSession + ? (usablePersonas.find((persona) => persona.code === spotlightSession.persona_code) ?? + null) + : selected, + [selected, spotlightSession, usablePersonas], + ); + const effectiveRecapDetail = + recapDetail && recapDetail.session_id === spotlightSession?.session_id ? recapDetail : null; + const spotlightAvatar = useMemo( + () => buildDashboardAvatar(spotlightPersona, spotlightSession?.persona_code), + [spotlightPersona, spotlightSession?.persona_code], + ); + const spotlightAffect = recapExpressionFor(spotlightSession, spotlightPersona); + const spotlightAffectLabel = expressionLabelFor(spotlightAffect); + const lastClientLine = lastTurnText(effectiveRecapDetail, "client"); + const lastLearnerLine = lastTurnText(effectiveRecapDetail, "learner"); + const reviewQueue = sortedSessions.filter((session) => session.review_ready).slice(0, 3); + const recentRecordRows = recentSessions.slice(0, 4); + const recapSessionTitle = + spotlightPersona?.display_name ?? spotlightSession?.persona_name ?? "첫 연습 대상 선택"; + const recapPersonaSubtitle = spotlightPersona + ? `${difficultyLine(spotlightPersona)} · ${theoryLine(spotlightPersona)}` + : spotlightSession + ? `${spotlightSession.persona_code} · ${sessionStatusLabel(spotlightSession)}` + : "학습 대상에서 내담자를 선택하세요"; + const recapProgress = stageProgressValue(spotlightSession?.stage); + const coachHints = spotlightSession + ? [ + "마지막 반응 먼저 반영", + spotlightSession.stage === "라포" ? "안전감 확인" : "감정 변화 확인", + spotlightSession.stage === "정리" ? "다음 약속 정리" : "탐색 질문 1개 준비", + ] + : ["낮은 난도부터 시작", "라포 목표 설정", "회기 종료 후 리뷰"]; + + const dashboardMetrics = [ + { + label: "진행 회기", + value: dashboardReady ? `${totalCount}회` : "-", + meta: dashboardReady ? `${completedCount}회 종료 · ${activeCount}회 진행` : dashboardStatus, + to: "/learn/history", + }, + { + label: "리뷰 대기", + value: dashboardReady ? `${reviewCount}건` : "-", + meta: reviewCount > 0 ? "종료 회기 피드백 확인 필요" : "대기 중인 리뷰 없음", + to: "/learn/history", + }, + { + label: "최근 평가", + value: dashboardReady ? scoreLabel(growth?.latest_score) : "-", + meta: + dashboardReady && growth?.evaluated_sessions + ? `${growth.evaluated_sessions}회 평가 · ${trendLabel(growth.trend)}` + : "첫 회기 후 표시", + to: "/learn/history", + }, + { + label: "라포 흐름", + value: dashboardReady ? rapportLabel(growth?.avg_rapport) : "-", + meta: topPersona?.total ? `${topPersona.name} · ${topPersona.total}회` : "아직 연습 없음", + to: "/learn/practice", + }, + ]; + + const renderSessionList = ( + rows: LearnerSessionSummary[], + emptyMessage = "저장된 기존 회기가 없습니다.", + ) => { + if (sessionLoadState === "loading") { + return

    저장된 연습 기록을 확인하고 있습니다.

    ; + } + if (sessionLoadState === "error") { + return ( +
    + + 연습 기록을 불러오지 못했습니다. +
    + ); + } + if (!rows.length) { + return ( +
    + +
    + {emptyMessage} +

    첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.

    +
    +
    + ); + } + return ( +
      + {rows.map((session) => { + const bucket = historyBucket(session); + const progress = stageProgressValue(session.stage); + return ( +
    • +
      +
      +
      +
      + {session.persona_code} + {shortPersonaName(session.persona_name)} +
      +

      + {session.stage} 단계 · {session.turn_count}턴 · {sessionDateLabel(session.started_at)} +

      +
      +
      + {session.status === "active" ? ( + + ) : ( + + )} + + {session.status === "ended" ? ( + + ) : null} +
      +
    • + ); + })} +
    + ); + }; + + const renderCompactSessionRows = ( + rows: LearnerSessionSummary[], + emptyMessage: string, + mode: "review" | "recent", + ) => { + if (sessionLoadState === "loading") { + return

    기록을 확인하고 있습니다.

    ; + } + if (sessionLoadState === "error") { + return ( +
    + + 기록을 불러오지 못했습니다. +
    + ); + } + if (!rows.length) { + return

    {emptyMessage}

    ; + } + return ( +
      + {rows.map((session) => ( +
    • + + + {shortPersonaName(session.persona_name)} + + {session.persona_code} · {session.stage} · {sessionDateLabel(session.started_at)} + + + {mode === "review" ? ( + + ) : ( + + )} +
    • + ))} +
    + ); + }; + + const renderPersonaList = () => { + if (loadState === "error") { + return ( +
    + +
    + 내담자 목록을 불러오지 못했습니다. +

    잠시 뒤 다시 시도해 주세요.

    +
    +
    + ); + } + return ( +
    + {loadState === "loading" + ? Array.from({ length: 3 }).map((_, i) => ( + + )) + : personas.map((persona) => { + const usablePersona = isUsablePersona(persona); + const selectedPersona = usablePersona && persona.code === selected?.code; + const personaMeta = + (DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) + + " · " + + theoryLine(persona); + return ( + + ); + })} +
    + ); + }; + return ( - +
    -
    -
    - 상담 연습 -

    오늘의 회기를 준비합니다.

    -

    - 내담자를 고르고, 진행 중 회기는 이어가고, 끝난 회기는 리뷰에서 다시 확인합니다. -

    -
    -
    - - {loadState === "ready" ? `${usablePersonas.length}명 · ${historyStatus}` : "확인 중"} -
    -
    - -
    -
    -
    - 연습 대상 - {loadState === "ready" ? "내담자 목록" : "확인 중"} -
    - - {loadState === "error" ? ( -
    - -
    - 내담자 목록을 불러오지 못했습니다. -

    잠시 뒤 다시 시도해 주세요.

    -
    -
    - ) : ( -
    - {loadState === "loading" - ? Array.from({ length: 3 }).map((_, i) => ( - - )) - : personas.map((persona) => { - const usablePersona = isUsablePersona(persona); - const selectedPersona = usablePersona && persona.code === selected?.code; - const personaMeta = - (DIFFICULTY_LABEL[persona.difficulty] ?? persona.difficulty) + - " · " + - theoryLine(persona); - return ( - - ); - })} -
    - )} -
    - -
    -
    -
    -
    - -
    - - {personaCatalogUnavailable ? "catalog" : (selected?.code ?? "...")} - -

    - {personaCatalogUnavailable - ? "사용 가능한 내담자가 없습니다." - : (selected?.display_name ?? "내담자 정보를 불러오는 중")} -

    -

    - {personaCatalogUnavailable - ? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다." - : demographicsLine(selected)} -

    -
    -
    - -
    - - 회기 종료 후 저장된 대화 기록으로 리뷰가 생성됩니다. - - -
    -
    - -
    - {personaCatalogUnavailable ? "카탈로그 상태" : "주호소"} -

    - {personaCatalogUnavailable - ? "현재 서버 카탈로그가 신뢰 가능한 상태로 확인되지 않아 연습 시작을 막았습니다. 카탈로그가 복구되면 선택할 수 있습니다." - : (selected?.presenting_summary ?? "내담자 정보를 확인하고 있습니다.")} + {view === "dashboard" ? ( + <> +

    +
    + 학습 대시보드 +

    오늘 이어갈 회기를 먼저 봅니다.

    +

    + 진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서 정리합니다.

    - -
    -
    -
    난도
    -
    {difficultyLine(selected)}
    +
    +
    + + {loadState === "ready" ? `${usablePersonas.length}명 · ${historyStatus}` : "확인 중"}
    -
    -
    접근
    -
    {theoryLine(selected)}
    -
    -
    -
    음성
    -
    {selected?.voice_preset ?? "기본"}
    -
    -
    -
    - -
    -
    - - {historyReady ? activeCount : "–"} - 진행 중 - - - {historyReady ? reviewCount : "–"} - 리뷰 가능 - - - {historyReady ? sessions.length : "–"} - 누적 회기 -
    -
    -
    - 기존 회기 - {selected ? `${selected.code} · 전체 ${historyReady ? sessions.length : "–"}회` : "-"} +
    + +
    +
    +
    +
    + {spotlightSession ? "오늘 이어갈 회기" : "첫 회기 준비"} +

    {recapSessionTitle}

    +

    {recapPersonaSubtitle}

    +
    + + {spotlightSession ? sessionStatusLabel(spotlightSession) : "대기"} +
    - {sessionLoadState === "loading" ? ( -

    저장된 연습 기록을 확인하고 있습니다.

    - ) : sessionLoadState === "error" ? ( -
    - - 연습 기록을 불러오지 못해 횟수를 표시하지 않습니다. + +
    +
    +
    - ) : personaCatalogUnavailable ? ( -
    - - 사용 가능한 내담자가 없어 기존 회기를 선택할 수 없습니다. -
    - ) : selectedSessions.length > 0 ? ( -
      - {selectedSessions.map((session) => ( -
    • - - {sessionStatusLabel(session)} - {sessionDateLabel(session.started_at)} - - - {session.turn_count}턴 · {session.stage} - - - {session.status === "active" ? ( - - ) : ( - - )} - - -
    • - ))} -
    - ) : ( -
    - -
    - 저장된 기존 회기가 없습니다. -

    첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.

    + +
    +
    + {spotlightSession?.persona_code ?? selected?.code ?? "P-"} + {spotlightSession ? `${spotlightSession.turn_count}턴` : "선택 전"} + {spotlightSession ? sessionDateLabel(spotlightSession.started_at) : "새 연습"} +
    + +
    +
    +
    단계
    +
    {spotlightSession?.stage ?? "선택 전"}
    +
    +
    +
    표정 힌트
    +
    {spotlightAffectLabel}
    +
    +
    +
    최근 흐름
    +
    {spotlightSession ? `${spotlightSession.learner_turn_count}회 응답` : "-"}
    +
    +
    + +
    +
    + 상담 진행 + {spotlightSession ? `${recapProgress}%` : "0%"} +
    +