Vignette session review
+{title}
+{description}
+강점 요약
+- {_list_items(share.goodMoments)}
개선 요약
+- {_list_items(share.growthPoints)}
사례개념화 핵심값
+- {_worksheet_items(share.worksheetHighlights)}
{html.escape(share.privacy)}
+ Vignette 열기 +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 "
Vignette session review
+{description}
+{html.escape(share.privacy)}
+ Vignette 열기 +{ticket.body}
{ticket.source_path || "출처 없음"} · 접수 {dateTimeLabel(ticket.created_at)} + {ticket.assigned_group ? ` · 담당 ${ticket.assigned_group}` : ""} + {ticket.event_count > 0 ? ` · 처리 이력 ${countLabel(ticket.event_count)}건` : ""}저장된 연습 기록을 확인하고 있습니다.
; + } + if (sessionLoadState === "error") { + return ( +첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.
++ {session.stage} 단계 · {session.turn_count}턴 · {sessionDateLabel(session.started_at)} +
+ +기록을 확인하고 있습니다.
; + } + if (sessionLoadState === "error") { + return ( +{emptyMessage}
; + } + return ( +잠시 뒤 다시 시도해 주세요.
+- 내담자를 고르고, 진행 중 회기는 이어가고, 끝난 회기는 리뷰에서 다시 확인합니다. -
-잠시 뒤 다시 시도해 주세요.
-- {personaCatalogUnavailable - ? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다." - : demographicsLine(selected)} -
-
- {personaCatalogUnavailable
- ? "현재 서버 카탈로그가 신뢰 가능한 상태로 확인되지 않아 연습 시작을 막았습니다. 카탈로그가 복구되면 선택할 수 있습니다."
- : (selected?.presenting_summary ?? "내담자 정보를 확인하고 있습니다.")}
+ {view === "dashboard" ? (
+ <>
+
+ 진행 중인 상담, 리뷰 대기, 다음 연습 기준을 한 화면에서 정리합니다.
{recapPersonaSubtitle} 저장된 연습 기록을 확인하고 있습니다. 첫 회기를 마치면 실제 저장 기록이 이곳에 쌓입니다.
+ {lastClientLine
+ ? `"${truncateText(lastClientLine)}"`
+ : recapLoadState === "loading"
+ ? "상세 발화를 확인하고 있습니다."
+ : "아직 표시할 내담자 발화가 없습니다."}
+ {lastLearnerLine ? truncateText(lastLearnerLine, 118) : "아직 기록된 학습자 발화가 없습니다."} {metric.meta} 최근 평가 피드백을 확인하고 있습니다. 최근 평가 피드백을 불러오지 못했습니다. 평가 피드백은 회기 종료 후 표시됩니다.
+ {reviewCount > 0
+ ? "새 회기를 시작하기 전에 이미 끝난 대화의 반응과 대안 발화를 확인하세요."
+ : "다음 회기에서는 감정 반영 뒤 무엇을 더 물을지 한 문장으로 정하고 들어갑니다."}
+ 한 대상에 치우치면 다른 주호소 대응력이 늦게 올라옵니다.
+ 대상별 주호소와 난도를 확인한 뒤 새 회기를 시작합니다. 기존 기록은 아래에서 이어가거나 리뷰합니다.
+
+ {personaCatalogUnavailable
+ ? "데이터베이스에서 확인된 내담자만 연습에 사용할 수 있습니다."
+ : demographicsLine(selected)}
+
+ {personaCatalogUnavailable
+ ? "현재 서버 카탈로그가 신뢰 가능한 상태로 확인되지 않아 연습 시작을 막았습니다."
+ : (selected?.presenting_summary ?? "내담자 정보를 확인하고 있습니다.")}
+
+ 이어갈 회기, 피드백 받을 회기, 보관된 회기를 분리해 다음 행동을 바로 선택합니다.
+ Vignette 가입 설정 {statusText}
+ {user?.email ? {user.email} : "현재 계정"}으로 가입 요청이 접수되었습니다.
+ 운영자가 수업 또는 연구 참여 범위를 확인하는 동안 Vignette 화면은 열리지 않습니다.
+ {guidance.desc}
+ {formError}
+ {message} {persona.source_provenance || "출처 정보 없음"} {persona.source_provenance || "출처 정보 없음"}
+ 배경 서사, 임상 개념화, 역린·금기, 회기별 시나리오, 프롬프트 미리보기와
+ 검수 요청을 한 작업면에서 관리합니다.
+ {personaUi.context.meta} 방금 턴과 근거 자료를 대조하는 중입니다. {coachSuggestion.message} {coachError} 코칭 모드에서는 방금 발화의 강점과 조정점을 근거와 함께 바로 짚습니다.
+ 회기 중 실제로 받은 코칭과 근거를 시간순으로 확인합니다.
+ {event.suggestion.message} {event.suggestion.rationale} 방금 코칭 판단에 사용한 근거와 다음 발화 제안입니다. {coachSuggestion.message} {coachSuggestion.rationale} {coachSuggestion.safety_note} 이번 코칭은 규칙 기반 신호로 생성됐고, 연결된 RAG 근거는 없습니다.
+ {renderInlineMarkdown(paragraph.join(" "))}
+
- {note.body}
+ {note.quote}오늘 이어갈 회기를 먼저 봅니다.
+
-
{recapSessionTitle}
+
- {selectedSessions.map((session) => (
-
- ) : (
-
+
+
+ 피드백 받을 기록입니다.
+ {reviewCount > 0 ? "리뷰 확인을 우선합니다." : "연습 목표를 좁힙니다."}
+ 방금 끝낸 흐름입니다.
+ 연습할 내담자를 선택합니다.
+
+ {personaCatalogUnavailable
+ ? "사용 가능한 내담자가 없습니다."
+ : (selected?.display_name ?? "내담자 정보를 불러오는 중")}
+
+
+
+ 회기 기록을 찾고 정리합니다.
+
+ {historyFilter === "all"
+ ? "전체 회기"
+ : HISTORY_FILTERS.find((item) => item.value === historyFilter)?.label}
+
+ 가입 정보를 입력합니다.
+ 계정 확인이 끝나면 바로 이용할 수 있습니다.
+
+ {guidance.items.map((item) => (
+
+ 내담자 설계·검수 작업면
+ {draft.display_name.trim() || "이름 미정"}
+ 초안 작성
- 공개 전 승인 큐
- 아직 닫지 않은 종료 회기
+ {session.session_id}
109 안전 확인 큐
- 109 안전 확인 큐
+ 종료된 실제 회기
- {session.session_id}
- 공개 전 승인 큐
+ 콘텐츠 저작실
+ {session.session_id}
- {session.session_id}
+
+ {personaUi.context.rows.slice(0, 3).map((row) => (
+
+ {coachSuggestion.next_utterance}
+ ) : null}
+ {coachHistoryTitle}
+ {event.suggestion.title}
+
+ {event.learner_text_excerpt}
+
+ ) : null}
+ {event.suggestion.next_utterance ? (
+ {event.suggestion.next_utterance}
+
+ {event.suggestion.sources.map((source, index) => (
+
+ ) : null}
+ {coachSuggestion.title}
+ {coachSuggestion.next_utterance}
+
+ {coachSources.map((source, index) => (
+
+ ) : (
+ {value});
+ cursor = end + 1;
+ } else {
+ const end = text.indexOf("**", markerAt + 2);
+ if (end < 0) {
+ pushText(text.slice(markerAt));
+ break;
+ }
+ const value = text.slice(markerAt + 2, end);
+ nodes.push({value});
+ cursor = end + 2;
+ }
+ }
+
+ return nodes;
+}
+
+function ReviewMarkdown({ text }: { text: string }) {
+ const lines = text.replace(/\r\n/g, "\n").split("\n");
+ const blocks: ReactNode[] = [];
+ let i = 0;
+
+ while (i < lines.length) {
+ const line = lines[i];
+ const trimmed = line.trim();
+
+ if (!trimmed) {
+ i += 1;
+ continue;
+ }
+
+ if (trimmed.startsWith(">")) {
+ const quoteLines: string[] = [];
+ while (i < lines.length && lines[i].trim().startsWith(">")) {
+ quoteLines.push(lines[i].trim().replace(/^>\s?/, ""));
+ i += 1;
+ }
+ blocks.push(
+
+ {quoteLines.map((quoteLine, index) => (
+ {renderInlineMarkdown(quoteLine)}
+ ))}
+
,
+ );
+ continue;
+ }
+
+ if (/^[-*]\s+/.test(trimmed)) {
+ const items: string[] = [];
+ while (i < lines.length && /^[-*]\s+/.test(lines[i].trim())) {
+ items.push(lines[i].trim().replace(/^[-*]\s+/, ""));
+ i += 1;
+ }
+ blocks.push(
+
+ {items.map((item, index) => (
+
,
+ );
+ continue;
+ }
+
+ const paragraph: string[] = [];
+ while (
+ i < lines.length &&
+ lines[i].trim() &&
+ !lines[i].trim().startsWith(">") &&
+ !/^[-*]\s+/.test(lines[i].trim())
+ ) {
+ paragraph.push(lines[i].trim());
+ i += 1;
+ }
+ blocks.push(
+
+ 발화 근거
+
) : null}
-
+